diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e84db50 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Version control +.git +.gitignore +.gitattributes + +# Dependencies +node_modules +npm-debug.log +yarn-debug.log +yarn-error.log + +# Environment +.env +.env.* + +# Development +.vscode +.idea +*.md +test +coverage +.prettierrc +jest.config.js + +# Build +dist +build + +# Docker +Dockerfile +.dockerignore + +# Misc +.DS_Store +*.log +*.swp +*.bak diff --git a/.env.test b/.env.test index 8684e3e..3b58092 100644 --- a/.env.test +++ b/.env.test @@ -2,4 +2,9 @@ POSTGRESQL_URL=postgres://root:password@192.168.1.120:8333/postgres MONGO_URL=mongodb://admin:123456@192.168.1.120:27017/db?authSource=admin JWT_SECRET=secret SESSION_EXPIRES_IN=1h -INITIAL_ADMIN_PASSWORD=123456 \ No newline at end of file +INITIAL_ADMIN_PASSWORD=123456 +PORT=6701 +DADATA_API_TOKEN= +GOOGLE_MAPS_API_KEY= +REDIS_URL=redis://localhost:6379 +DEV_SECRET_KEY=123456 \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 71943fe..78d4c00 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,7 +34,7 @@ module.exports = { ], "newlines-between": "always", alphabetize: { - order: "asc" /* asc или desc */, + order: "asc", caseInsensitive: true, }, }, @@ -51,5 +51,36 @@ module.exports = { "import/first": "error", "import/newline-after-import": "error", "import/no-duplicates": "error", + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "class-validator", + message: + "Import custom validators from @i18n-class-validator instead", + }, + ], + patterns: [ + { + group: ["class-validator/*"], + message: + "Import custom validators from @i18n-class-validator instead", + }, + ], + }, + ], + "import/no-restricted-paths": [ + "error", + { + zones: [ + { + target: "./src/**/*", + from: "class-validator", + except: ["./src/i18n/validators/index.ts"], + }, + ], + }, + ], }, }; diff --git a/.example.env b/.example.env index 1b4a4be..2def50f 100644 --- a/.example.env +++ b/.example.env @@ -1,4 +1,11 @@ POSTGRESQL_URL=postgres://root:password@164.92.197.53:8333/testdb JWT_SECRET=secret SESSION_EXPIRES_IN=1s -INITIAL_ADMIN_PASSWORD=123456 \ No newline at end of file +INITIAL_ADMIN_PASSWORD=123456 +PORT=6701 +DADATA_API_TOKEN= +GOOGLE_MAPS_API_KEY= +REDIS_URL= +API_SECRET_KEY= +DEV_SECRET_KEY= +COOKIES_SECRET= \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index cb9915a..d796e90 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,14 @@ { - "javascript.updateImportsOnFileMove.enabled": "always", - "typescript.updateImportsOnFileMove.enabled": "always", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "npm.packageManager": "yarn" - } \ No newline at end of file + "javascript.updateImportsOnFileMove.enabled": "always", + "typescript.updateImportsOnFileMove.enabled": "always", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "npm.packageManager": "yarn", + "workbench.colorCustomizations": { + "minimap.background": "#00000000", + "scrollbar.shadow": "#00000000" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7b13ffb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Build stage +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install python and build dependencies +RUN apk add --no-cache python3 make g++ + +COPY package.json yarn.lock ./ + +# Install dependencies +RUN yarn install --frozen-lockfile + +COPY . . + +# Build the application +RUN yarn build + +# Production stage +FROM node:22-alpine AS production + +WORKDIR /app + +# Install python and build dependencies for production packages that need compilation +RUN apk add --no-cache python3 make g++ + +COPY package.json yarn.lock ./ + +# Install only production dependencies +RUN yarn install --frozen-lockfile --production + +FROM node:22-alpine AS runner +WORKDIR /app + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs + +COPY --from=production /app/node_modules ./node_modules + +RUN mkdir dist + +RUN chown nestjs:nodejs dist +RUN chown nestjs:nodejs node_modules + +# Copy built assets from builder stage +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/src/i18n ./src/i18n +COPY --from=builder /app/src/i18n/messages ./dist/i18n/messages + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=3000 + +# Expose the port the app runs on +EXPOSE ${PORT} + +USER nestjs + +# Start the application +CMD ["node", "dist/src/main.js"] diff --git a/deployment/.example.env b/deployment/.example.env new file mode 100644 index 0000000..4a6e034 --- /dev/null +++ b/deployment/.example.env @@ -0,0 +1,25 @@ +DOMAIN=demo.toite.ee + +# Docker images +BACKEND_REPO_IMAGE=yefro/toite-backend:latest +FRONTEND_REPO_IMAGE=yefro/toite-frontend:latest + +# Secrets +# openssl rand -base64 32 +JWT_SECRET= +CSRF_SECRET= +COOKIES_SECRET= +INITIAL_ADMIN_PASSWORD= +REDIS_PASSWORD= +POSTGRES_PASSWORD= +MONGO_PASSWORD= + +# S3 +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_BUCKET_NAME= +S3_ENDPOINT= + +# Api keys +DADATA_API_TOKEN= +GOOGLE_MAPS_API_KEY= \ No newline at end of file diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml new file mode 100644 index 0000000..35d745b --- /dev/null +++ b/deployment/docker-compose.yml @@ -0,0 +1,162 @@ +version: "3.9" +name: "toite" + +services: + traefik: + container_name: traefik + image: traefik:v3.3 + ports: + - "80:80" + - "443:443" + - "443:443/tcp" # HTTP/3 + - "443:443/udp" # HTTP/3 + - "8080:8080" + networks: + toite-network: + ipv4_address: 172.36.0.2 + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./traefik.yml:/etc/traefik/traefik.yml:ro" # Static configuration + - "traefik-data:/data" # Certificates and etc + labels: + - "traefik.enable=true" + # Global redirection to https + - "traefik.http.routers.http-catchall.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.http-catchall.entrypoints=web" + - "traefik.http.routers.http-catchall.middlewares=redirect-to-https" + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + + # Running mongo database + mongo: + container_name: mongo + build: + context: mongo + dockerfile: Dockerfile + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: toite + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} + ports: + - "27017:27017" + networks: + toite-network: + ipv4_address: 172.36.0.3 + volumes: + - "mongo-data:/data/db" + + # Running simple postgres database + postgres: + container_name: postgres + image: postgres + restart: unless-stopped + environment: + PGDATA: /data/postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "5432:5432" + networks: + toite-network: + ipv4_address: 172.36.0.4 + volumes: + - "postgres-data:/data/postgres" + + # Running redis cache + redis: + container_name: redis + image: redis:6.2-alpine + command: redis-server --save 20 1 --loglevel warning --requirepass "${REDIS_PASSWORD}" + restart: unless-stopped + ports: + - "6379:6379" + networks: + toite-network: + ipv4_address: 172.36.0.5 + volumes: + - "redis-data:/data" + + backend: + container_name: toite-backend + image: ${BACKEND_REPO_IMAGE:-yefro/toite-backend:latest} + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 5000 + MONGO_URL: mongodb://toite:${MONGO_PASSWORD}@172.36.0.3:27017/toite?authSource=admin&directConnection=true + POSTGRESQL_URL: postgres://postgres:${POSTGRES_PASSWORD}@172.36.0.4:5432/postgres + JWT_SECRET: ${JWT_SECRET} + CSRF_SECRET: ${CSRF_SECRET} + SESSION_EXPIRES_IN: 4h + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD} + DADATA_API_TOKEN: ${DADATA_API_TOKEN} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY} + REDIS_URL: redis://:${REDIS_PASSWORD}@172.36.0.5:6379 + COOKIES_SECRET: ${COOKIES_SECRET} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} + S3_BUCKET_NAME: ${S3_BUCKET_NAME} + S3_ENDPOINT: ${S3_ENDPOINT} + S3_REGION: ${S3_REGION} + ports: + - "5000:5000" + networks: + toite-network: + ipv4_address: 172.36.0.10 + depends_on: + - redis + - postgres + - mongo + labels: + - "traefik.enable=true" + - "traefik.http.routers.backend.rule=Host(`${DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/socket.io`))" + - "traefik.http.routers.backend.entrypoints=websecure" + - "traefik.http.routers.backend.tls.certresolver=letsencrypt" + - "traefik.http.services.backend.loadbalancer.server.port=5000" + - "traefik.http.routers.backend.middlewares=strip-api" # Apply the middleware + + # Define the middleware to strip the /api prefix + - "traefik.http.middlewares.strip-api.stripprefix.prefixes=/api" + + frontend: + container_name: toite-frontend + image: ${FRONTEND_REPO_IMAGE:-yefro/toite-frontend:latest} + restart: unless-stopped + environment: + NODE_ENV: production + NEXT_INTERNAL_API_URL: http://172.36.0.10 + ports: + - "3000:3000" + networks: + toite-network: + ipv4_address: 172.36.0.11 + depends_on: + - backend + labels: + - "traefik.enable=true" + - "traefik.http.routers.frontend.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.frontend.entrypoints=websecure" + - "traefik.http.routers.frontend.tls.certresolver=letsencrypt" + - "traefik.http.services.frontend.loadbalancer.server.port=3000" + +volumes: + # SSL and etc. + traefik-data: + driver: local + # Mongo data storage + mongo-data: + driver: local + # Postgres data storage + postgres-data: + driver: local + # Redis data storage + redis-data: + driver: local + +networks: + # Shared network + toite-network: + driver: bridge + ipam: + config: + - subnet: 172.36.0.0/16 + gateway: 172.36.0.1 diff --git a/deployment/mongo/Dockerfile b/deployment/mongo/Dockerfile new file mode 100644 index 0000000..d334a4f --- /dev/null +++ b/deployment/mongo/Dockerfile @@ -0,0 +1,19 @@ +ARG MONGO_VERSION=6 + +FROM mongo:${MONGO_VERSION} + +ENV MONGO_REPLICA_HOST=127.0.0.1 +ENV MONGO_REPLICA_PORT=27017 +ENV MONGO_COMMAND=mongosh + +# Create keyFile for replica set authentication +RUN openssl rand -base64 756 > /data/keyfile && \ + chmod 400 /data/keyfile && \ + chown 999:999 /data/keyfile + +# Copy the init script +COPY ./init.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/init.sh + +# Set the entrypoint to our init script +ENTRYPOINT ["/usr/local/bin/init.sh"] \ No newline at end of file diff --git a/deployment/mongo/init.sh b/deployment/mongo/init.sh new file mode 100644 index 0000000..9f029b2 --- /dev/null +++ b/deployment/mongo/init.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +set -e + +HASH_FILE="/data/credentials.hash" + +hash_credentials() { + echo -n "$MONGO_INITDB_ROOT_USERNAME:$MONGO_INITDB_ROOT_PASSWORD" | sha256sum | awk '{print $1}' +} + +user_needs_update() { + local current_hash + current_hash=$(hash_credentials) + if [ -f "$HASH_FILE" ]; then + local stored_hash + stored_hash=$(cat "$HASH_FILE") + if [ "$current_hash" == "$stored_hash" ]; then + return 1 # No update needed + fi + fi + return 0 # Update needed +} + +shutdown_mongo() { + echo "SHUTTING DOWN MONGO" + mongosh --port $MONGO_REPLICA_PORT --eval "db.adminCommand({ shutdown: 1 })" + + wait $MONGO_PID +} + +start_mongo_no_auth() { + mongod --port $MONGO_REPLICA_PORT --replSet rs0 --bind_ip 0.0.0.0 & + MONGO_PID=$! + echo "REPLICA SET ONLINE WITHOUT AUTHENTICATION" + + until mongosh --port $MONGO_REPLICA_PORT --eval "print(\"waited for connection\")" > /dev/null 2>&1 + do + sleep 2 + done +} + +start_mongo_with_auth() { + mongod --port $MONGO_REPLICA_PORT --replSet rs0 --bind_ip 0.0.0.0 --auth --keyFile /data/keyfile & + MONGO_PID=$! + echo "REPLICA SET ONLINE WITH AUTHENTICATION" + + until mongosh --port $MONGO_REPLICA_PORT -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase "admin" --eval "print(\"waited for connection\")" > /dev/null 2>&1 + do + sleep 2 + done +} + +create_user_from_env() { + echo "Creating new user from environment variables..." + mongosh --port $MONGO_REPLICA_PORT --eval "db.getSiblingDB('admin').createUser({user: '$MONGO_INITDB_ROOT_USERNAME', pwd: '$MONGO_INITDB_ROOT_PASSWORD', roles: ['root'], passwordDigestor: 'server'})" + + hash_credentials > "$HASH_FILE" +} + +delete_all_users() { + echo "Deleting all users from the admin database..." + mongosh --port $MONGO_REPLICA_PORT --eval " + db.getSiblingDB('admin').dropAllUsers(); + " +} + +init_replica_set() { + echo "Initializing replica set..." + mongosh --port $MONGO_REPLICA_PORT --eval "rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: '$MONGO_REPLICA_HOST:$MONGO_REPLICA_PORT' }] })" + sleep 5 +} + +if [ ! -d "/data/db/diagnostic.data" ]; then + echo "First time setup..." + + start_mongo_no_auth + init_replica_set + + if user_needs_update; then + echo "Creating or updating root user..." + create_user_from_env + else + echo "User credentials have not changed." + fi + + sleep 5 + + echo "Shutting down MongoDB after initial setup..." + shutdown_mongo +else + echo "Replica set already initialized." + + if user_needs_update; then + start_mongo_no_auth + + delete_all_users + create_user_from_env + + sleep 5 + + shutdown_mongo + else + echo "User credentials have not changed." + fi +fi + +start_mongo_with_auth + +echo "EVERYTHING IS READY" + +# Keep the container running +tail -f /dev/null & wait \ No newline at end of file diff --git a/deployment/traefik.yml b/deployment/traefik.yml new file mode 100644 index 0000000..022ab1c --- /dev/null +++ b/deployment/traefik.yml @@ -0,0 +1,21 @@ +api: + dashboard: true # Enable the dashboard + insecure: true # disable in production + +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + +providers: + docker: + exposedByDefault: false # Don't expose all containers by default + +certificatesResolvers: + letsencrypt: + acme: + email: "contact@yefro.dev" + storage: /data/acme.json + httpChallenge: + entryPoint: web diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b07f3b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3.8" + +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "${PORT:-3000}:3000" + env_file: + - .env + restart: unless-stopped + network_mode: host diff --git a/drizzle.config.ts b/drizzle.config.ts index 781d396..5f2da55 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,10 +6,10 @@ dotenv.config({ }); export default { - schema: "./src/drizzle/schema/index.ts", - out: "./src/drizzle/migrations", - driver: "pg", + schema: "./src/@base/drizzle/schema", + out: "./src/@base/drizzle/migrations", + dialect: "postgresql", dbCredentials: { - connectionString: process.env.POSTGRESQL_URL, + url: String(process.env.POSTGRESQL_URL), }, } satisfies Config; diff --git a/nest-cli.json b/nest-cli.json index f9aa683..07297d2 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,15 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { + "builder": "swc", + "typeCheck": true, + "assets": [ + { + "include": "i18n/messages/**/*", + "watchAssets": true, + "outDir": "dist/src" + } + ], "deleteOutDir": true } } diff --git a/package.json b/package.json index 992458a..55231b7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", + "start:dev:second": "cross-env PORT=3001 nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", @@ -18,69 +19,104 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --runInBand", - "db:generate": "drizzle-kit generate:pg", - "db:push": "drizzle-kit push:pg ", - "db:push:test": "cross-env NODE_ENV=test drizzle-kit push:pg", - "db:migrate": "node -r esbuild-register src/drizzle/migrate.ts", - "db:clear": "node -r esbuild-register src/drizzle/clear.ts", - "db:clear:test": "cross-env NODE_ENV=test node -r esbuild-register src/drizzle/clear.ts", - "db:migrate drop": "drizzle-kit drop", - "db:seed": "node -r esbuild-register src/drizzle/seed.ts" + "db:generate": "drizzle-kit generate", + "db:studio": "drizzle-kit studio", + "db:push": "drizzle-kit push", + "db:push:test": "cross-env NODE_ENV=test drizzle-kit push", + "db:migrate": "node -r esbuild-register src/@base/drizzle/migrate.ts", + "db:clear": "node -r esbuild-register src/@base/drizzle/clear.ts", + "db:clear:test": "cross-env NODE_ENV=test node -r esbuild-register src/@base/drizzle/clear.ts", + "db:migrate:drop": "drizzle-kit drop", + "db:test:start": "bash ./utils/seed/start-db.sh", + "db:test:push": "drizzle-kit push --config ./utils/seed/drizzle.config.ts", + "db:test:seed": "node -r esbuild-register ./utils/seed/index.ts", + "prepare:test": "yarn db:test:start && yarn db:test:push && yarn db:test:seed", + "bench": "k6 run ./utils/bench/index.js" }, "dependencies": { + "@aws-sdk/client-s3": "^3.726.1", + "@fastify/cookie": "9.4.0", + "@fastify/multipart": "8.3.1", + "@fastify/static": "7.0.4", + "@liaoliaots/nestjs-redis": "^10.0.0", "@lukeed/ms": "^2.0.2", - "@nestjs/common": "^10.0.0", + "@nestjs/axios": "^3.1.3", + "@nestjs/bullmq": "^11.0.2", + "@nestjs/common": "10.4.15", "@nestjs/config": "^3.1.1", - "@nestjs/core": "^10.0.0", + "@nestjs/core": "10.4.15", "@nestjs/jwt": "^10.2.0", "@nestjs/mongoose": "^10.0.4", "@nestjs/passport": "^10.0.3", - "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-fastify": "10.4.15", + "@nestjs/platform-socket.io": "^11.0.8", "@nestjs/swagger": "^7.2.0", "@nestjs/throttler": "^5.1.1", - "argon2": "^0.31.2", + "@nestjs/websockets": "^11.0.8", + "@sesamecare-oss/redlock": "^1.4.0", + "@supercharge/request-ip": "^1.2.0", + "@vvo/tzdb": "^6.157.0", + "argon2": "^0.41.1", + "axios": "^1.8.4", + "bullmq": "^5.40.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", - "cookie-parser": "^1.4.6", - "drizzle-orm": "^0.29.3", - "drizzle-zod": "^0.5.1", + "cookie": "^1.0.2", + "csrf-csrf": "^3.1.0", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "dotenv": "^16.4.7", + "drizzle-orm": "0.38.3", + "drizzle-zod": "0.6.1", "eslint-plugin-import": "^2.29.1", + "ioredis": "^5.4.2", "jsonwebtoken": "^9.0.2", - "mongoose": "^8.2.0", - "nestjs-zod": "^3.0.0", + "mongoose": "^8.13.0", + "multer": "^1.4.5-lts.1", + "nestjs-form-data": "^1.9.92", + "nestjs-i18n": "^10.5.1", + "nestjs-zod": "^4.2.0", "passport-jwt": "^4.0.1", - "pg": "^8.11.3", + "pg": "^8.14.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "slugify": "^1.6.6", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1", + "swagger-themes": "^1.4.3", "uuid": "^9.0.1", - "zod": "^3.22.4" + "zod": "^3.24.2" }, "devDependencies": { "@faker-js/faker": "^8.3.1", "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", - "@types/cookie-parser": "^1.4.6", + "@swc/cli": "^0.6.0", + "@swc/core": "^1.11.13", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.12", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.0", + "@types/pg": "^8.11.10", "@types/supertest": "^6.0.0", + "@types/uuid": "^10.0.0", + "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "cross-env": "^7.0.3", - "drizzle-kit": "^0.20.13", + "drizzle-kit": "^0.30.1", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", - "jest": "^29.5.0", + "jest": "^29.7.0", "prettier": "^3.0.0", "source-map-support": "^0.5.21", "supertest": "^6.3.4", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" } diff --git a/root@toite-demo/deployment/.example.env b/root@toite-demo/deployment/.example.env new file mode 100644 index 0000000..ba12ad1 --- /dev/null +++ b/root@toite-demo/deployment/.example.env @@ -0,0 +1,22 @@ +# Docker images +API_REPO_IMAGE=yefro/toite-backend:latest + +# Secrets +# openssl rand -base64 32 +JWT_SECRET= +CSRF_SECRET= +COOKIES_SECRET= +INITIAL_ADMIN_PASSWORD= +REDIS_PASSWORD= +POSTGRES_PASSWORD= +MONGO_PASSWORD= + +# S3 +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_BUCKET_NAME= +S3_ENDPOINT= + +# Api keys +DADATA_API_TOKEN= +GOOGLE_MAPS_API_KEY= \ No newline at end of file diff --git a/root@toite-demo/deployment/docker-compose.yml b/root@toite-demo/deployment/docker-compose.yml new file mode 100644 index 0000000..48aaa2b --- /dev/null +++ b/root@toite-demo/deployment/docker-compose.yml @@ -0,0 +1,104 @@ +version: "3.8" +name: "toite" + +services: + # Running mongo database + mongo: + container_name: mongo + build: + context: mongo + dockerfile: Dockerfile + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: toite + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} + ports: + - "27017:27017" + networks: + toite-network: + ipv4_address: 172.36.0.3 + volumes: + - "mongo-data:/data/db" + + # Running simple postgres database + postgres: + container_name: postgres + image: postgres + restart: unless-stopped + environment: + PGDATA: /data/postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "5432:5432" + networks: + toite-network: + ipv4_address: 172.36.0.4 + volumes: + - "postgres-data:/data/postgres" + + # Running redis cache + redis: + container_name: redis + image: redis:6.2-alpine + command: redis-server --save 20 1 --loglevel warning --requirepass "${REDIS_PASSWORD}" + restart: unless-stopped + ports: + - "6379:6379" + networks: + toite-network: + ipv4_address: 172.36.0.5 + volumes: + - "redis-data:/data" + + api: + container_name: toite-api + image: ${API_REPO_IMAGE:-yefro/toite-backend:latest} + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 5000 + MONGO_URL: mongodb://toite:${MONGO_PASSWORD}@172.36.0.3:27017/toite?authSource=admin&directConnection=true + POSTGRESQL_URL: postgres://postgres:${POSTGRES_PASSWORD}@172.36.0.4:5432/toite + JWT_SECRET: ${JWT_SECRET} + CSRF_SECRET: ${CSRF_SECRET} + SESSION_EXPIRES_IN: 4h + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD} + DADATA_API_TOKEN: ${DADATA_API_TOKEN} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY} + REDIS_URL: redis://:${REDIS_PASSWORD}@172.36.0.5:6379 + COOKIES_SECRET: ${COOKIES_SECRET} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} + S3_BUCKET_NAME: ${S3_BUCKET_NAME} + S3_ENDPOINT: ${S3_ENDPOINT} + S3_REGION: ${S3_REGION} + ports: + - "5000:5000" + networks: + toite-network: + ipv4_address: 172.36.0.10 + depends_on: + - redis + - postgres + - mongo + +volumes: + # Mongo data storage + mongo-data: + driver: local + # Postgres data storage + postgres-data: + driver: local + # Redis data storage + redis-data: + driver: local + +networks: + # Shared network + toite-network: + driver: bridge + ipam: + config: + - subnet: 172.36.0.0/16 + gateway: 172.36.0.1 diff --git a/root@toite-demo/deployment/mongo/Dockerfile b/root@toite-demo/deployment/mongo/Dockerfile new file mode 100644 index 0000000..d334a4f --- /dev/null +++ b/root@toite-demo/deployment/mongo/Dockerfile @@ -0,0 +1,19 @@ +ARG MONGO_VERSION=6 + +FROM mongo:${MONGO_VERSION} + +ENV MONGO_REPLICA_HOST=127.0.0.1 +ENV MONGO_REPLICA_PORT=27017 +ENV MONGO_COMMAND=mongosh + +# Create keyFile for replica set authentication +RUN openssl rand -base64 756 > /data/keyfile && \ + chmod 400 /data/keyfile && \ + chown 999:999 /data/keyfile + +# Copy the init script +COPY ./init.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/init.sh + +# Set the entrypoint to our init script +ENTRYPOINT ["/usr/local/bin/init.sh"] \ No newline at end of file diff --git a/root@toite-demo/deployment/mongo/init.sh b/root@toite-demo/deployment/mongo/init.sh new file mode 100644 index 0000000..9f029b2 --- /dev/null +++ b/root@toite-demo/deployment/mongo/init.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +set -e + +HASH_FILE="/data/credentials.hash" + +hash_credentials() { + echo -n "$MONGO_INITDB_ROOT_USERNAME:$MONGO_INITDB_ROOT_PASSWORD" | sha256sum | awk '{print $1}' +} + +user_needs_update() { + local current_hash + current_hash=$(hash_credentials) + if [ -f "$HASH_FILE" ]; then + local stored_hash + stored_hash=$(cat "$HASH_FILE") + if [ "$current_hash" == "$stored_hash" ]; then + return 1 # No update needed + fi + fi + return 0 # Update needed +} + +shutdown_mongo() { + echo "SHUTTING DOWN MONGO" + mongosh --port $MONGO_REPLICA_PORT --eval "db.adminCommand({ shutdown: 1 })" + + wait $MONGO_PID +} + +start_mongo_no_auth() { + mongod --port $MONGO_REPLICA_PORT --replSet rs0 --bind_ip 0.0.0.0 & + MONGO_PID=$! + echo "REPLICA SET ONLINE WITHOUT AUTHENTICATION" + + until mongosh --port $MONGO_REPLICA_PORT --eval "print(\"waited for connection\")" > /dev/null 2>&1 + do + sleep 2 + done +} + +start_mongo_with_auth() { + mongod --port $MONGO_REPLICA_PORT --replSet rs0 --bind_ip 0.0.0.0 --auth --keyFile /data/keyfile & + MONGO_PID=$! + echo "REPLICA SET ONLINE WITH AUTHENTICATION" + + until mongosh --port $MONGO_REPLICA_PORT -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase "admin" --eval "print(\"waited for connection\")" > /dev/null 2>&1 + do + sleep 2 + done +} + +create_user_from_env() { + echo "Creating new user from environment variables..." + mongosh --port $MONGO_REPLICA_PORT --eval "db.getSiblingDB('admin').createUser({user: '$MONGO_INITDB_ROOT_USERNAME', pwd: '$MONGO_INITDB_ROOT_PASSWORD', roles: ['root'], passwordDigestor: 'server'})" + + hash_credentials > "$HASH_FILE" +} + +delete_all_users() { + echo "Deleting all users from the admin database..." + mongosh --port $MONGO_REPLICA_PORT --eval " + db.getSiblingDB('admin').dropAllUsers(); + " +} + +init_replica_set() { + echo "Initializing replica set..." + mongosh --port $MONGO_REPLICA_PORT --eval "rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: '$MONGO_REPLICA_HOST:$MONGO_REPLICA_PORT' }] })" + sleep 5 +} + +if [ ! -d "/data/db/diagnostic.data" ]; then + echo "First time setup..." + + start_mongo_no_auth + init_replica_set + + if user_needs_update; then + echo "Creating or updating root user..." + create_user_from_env + else + echo "User credentials have not changed." + fi + + sleep 5 + + echo "Shutting down MongoDB after initial setup..." + shutdown_mongo +else + echo "Replica set already initialized." + + if user_needs_update; then + start_mongo_no_auth + + delete_all_users + create_user_from_env + + sleep 5 + + shutdown_mongo + else + echo "User credentials have not changed." + fi +fi + +start_mongo_with_auth + +echo "EVERYTHING IS READY" + +# Keep the container running +tail -f /dev/null & wait \ No newline at end of file diff --git a/src/@base/audit-logs/audit-logs.interceptor.ts b/src/@base/audit-logs/audit-logs.interceptor.ts new file mode 100644 index 0000000..24c0626 --- /dev/null +++ b/src/@base/audit-logs/audit-logs.interceptor.ts @@ -0,0 +1,153 @@ +import { Request } from "@core/interfaces/request"; +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import * as requestIp from "@supercharge/request-ip"; +import { I18nContext } from "nestjs-i18n"; +import { Observable, tap } from "rxjs"; +import { v4 as uuidv4 } from "uuid"; + +import { AuditLogsProducer } from "./audit-logs.producer"; +import { + AUDIT_LOG_OPTIONS, + AuditLogOptions, + IS_AUDIT_LOG_ENABLED, +} from "./decorators/audit-logs.decorator"; + +@Injectable() +export class AuditLogsInterceptor implements NestInterceptor { + constructor( + private readonly reflector: Reflector, + private readonly auditLogsProducer: AuditLogsProducer, + ) {} + + public readonly sensitiveFields = ["password", "token", "refreshToken"]; + + public readonly sensitiveFieldsRegex = new RegExp( + this.sensitiveFields.join("|"), + "gi", + ); + + public filterSensitiveFields(obj: any) { + if (!obj) return {}; + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [ + key, + this.sensitiveFieldsRegex.test(key) ? "****" : value, + ]), + ); + } + + public getUserAgent(request: Request) { + return ( + request.headers["user-agent"] ?? (request.headers["User-Agent"] as string) + ); + } + + public getIpAddress(request: Request) { + return requestIp.getClientIp(request); + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const timestamp = Date.now(); + + request.timestamp = timestamp; + + const isEnabled = this.reflector.get( + IS_AUDIT_LOG_ENABLED, + context.getHandler(), + ); + + if (!isEnabled) { + return next.handle(); + } + + const options = + this.reflector.get( + AUDIT_LOG_OPTIONS, + context.getHandler(), + ) ?? {}; + + const startTime = Date.now(); + request.requestId = request?.requestId ?? uuidv4(); + + const body = this.filterSensitiveFields(request.body); + const params = this.filterSensitiveFields(request.params); + const query = this.filterSensitiveFields(request.query); + const headers = this.filterSensitiveFields(request.headers); + + const userAgent = this.getUserAgent(request); + const ipAddress = this.getIpAddress(request); + + // Extract session and worker IDs + const sessionId = request.session?.id; + const workerId = request.worker?.id; + + return next.handle().pipe( + tap({ + next: (response) => { + if (options.onlyErrors) { + return; + } + + const duration = Date.now() - startTime; + + this.auditLogsProducer.create({ + method: request.method, + url: request.url, + params, + query, + body, + headers, + userAgent, + ipAddress, + userId: request.user?.id, + sessionId, + workerId, + response, + statusCode: context.switchToHttp().getResponse().statusCode, + duration, + requestId: request.requestId, + origin: request.headers.origin, + isFailed: false, + }); + }, + error: (error) => { + const duration = Date.now() - startTime; + const i18n = I18nContext.current(); + + this.auditLogsProducer.create({ + method: request.method, + url: request.url, + params, + query, + body, + headers, + userAgent, + ipAddress, + userId: request.user?.id, + sessionId, + workerId, + error: { + message: error.message, + messageI18n: i18n?.t(error.message), + stack: error.stack, + name: error.name, + code: error.code, + }, + statusCode: error?.status ?? 500, + duration, + requestId: request.requestId, + origin: request.headers.origin, + isFailed: true, + }); + }, + }), + ); + } +} diff --git a/src/@base/audit-logs/audit-logs.module.ts b/src/@base/audit-logs/audit-logs.module.ts new file mode 100644 index 0000000..9233c57 --- /dev/null +++ b/src/@base/audit-logs/audit-logs.module.ts @@ -0,0 +1,30 @@ +import { BullModule } from "@nestjs/bullmq"; +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; + +import { AuditLogsInterceptor } from "./audit-logs.interceptor"; +import { AuditLogsProcessor } from "./audit-logs.processor"; +import { AuditLogsProducer } from "./audit-logs.producer"; +import { AuditLogsService } from "./audit-logs.service"; +import { AuditLog, AuditLogSchema } from "./schemas/audit-log.schema"; +import { AUDIT_LOGS_QUEUE } from "./types"; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: AuditLog.name, schema: AuditLogSchema }, + ]), + BullModule.registerQueue({ + name: AUDIT_LOGS_QUEUE, + }), + ], + controllers: [], + providers: [ + AuditLogsService, + AuditLogsInterceptor, + AuditLogsProcessor, + AuditLogsProducer, + ], + exports: [AuditLogsProducer], +}) +export class AuditLogsModule {} diff --git a/src/@base/audit-logs/audit-logs.processor.ts b/src/@base/audit-logs/audit-logs.processor.ts new file mode 100644 index 0000000..7bccea3 --- /dev/null +++ b/src/@base/audit-logs/audit-logs.processor.ts @@ -0,0 +1,40 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Job } from "bullmq"; + +import { AuditLogsService } from "./audit-logs.service"; +import { AuditLog } from "./schemas/audit-log.schema"; +import { AUDIT_LOGS_QUEUE, AuditLogQueueJobName } from "./types"; + +@Processor(AUDIT_LOGS_QUEUE, {}) +export class AuditLogsProcessor extends WorkerHost { + private readonly logger = new Logger(AuditLogsProcessor.name); + + constructor(private readonly service: AuditLogsService) { + super(); + } + + async process(job: Job) { + const { name, data } = job; + + try { + switch (name) { + case AuditLogQueueJobName.CREATE_AUDIT_LOG: { + await this.createAuditLog(data as Partial); + break; + } + + default: { + throw new Error(`Unknown job name: ${name}`); + } + } + } catch (error) { + this.logger.error(`Failed to process ${name} job`, error); + throw error; + } + } + + private async createAuditLog(payload: Partial) { + await this.service.create(payload); + } +} diff --git a/src/@base/audit-logs/audit-logs.producer.ts b/src/@base/audit-logs/audit-logs.producer.ts new file mode 100644 index 0000000..ebb2f22 --- /dev/null +++ b/src/@base/audit-logs/audit-logs.producer.ts @@ -0,0 +1,36 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable, Logger } from "@nestjs/common"; +import { JobsOptions, Queue } from "bullmq"; + +import { AuditLog } from "./schemas/audit-log.schema"; +import { AUDIT_LOGS_QUEUE, AuditLogQueueJobName } from "./types"; + +@Injectable() +export class AuditLogsProducer { + private readonly logger = new Logger(AuditLogsProducer.name); + + constructor( + @InjectQueue(AUDIT_LOGS_QUEUE) + private readonly queue: Queue, + ) {} + + private async addJob( + name: AuditLogQueueJobName, + data: any, + opts?: JobsOptions, + ) { + try { + return await this.queue.add(name, data, opts); + } catch (error) { + this.logger.error(`Failed to add ${name} job to queue:`, error); + throw error; + } + } + + /** + * Creates a task to create an audit log + */ + public async create(payload: Partial) { + return this.addJob(AuditLogQueueJobName.CREATE_AUDIT_LOG, payload); + } +} diff --git a/src/@base/audit-logs/audit-logs.service.ts b/src/@base/audit-logs/audit-logs.service.ts new file mode 100644 index 0000000..4ffa85c --- /dev/null +++ b/src/@base/audit-logs/audit-logs.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; + +import { AuditLog, AuditLogDocument } from "./schemas/audit-log.schema"; + +@Injectable() +export class AuditLogsService { + constructor( + @InjectModel(AuditLog.name) + private readonly auditLogModel: Model, + ) {} + + async create(auditLogData: Partial): Promise { + // Remove undefined values + const cleanData = Object.fromEntries( + Object.entries(auditLogData).filter(([, value]) => value !== undefined), + ); + + const auditLog = new this.auditLogModel(cleanData); + return auditLog.save(); + } +} diff --git a/src/@base/audit-logs/decorators/audit-logs.decorator.ts b/src/@base/audit-logs/decorators/audit-logs.decorator.ts new file mode 100644 index 0000000..eb5e599 --- /dev/null +++ b/src/@base/audit-logs/decorators/audit-logs.decorator.ts @@ -0,0 +1,15 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_AUDIT_LOG_ENABLED = "IS_AUDIT_LOG_ENABLED"; +export const AUDIT_LOG_OPTIONS = "AUDIT_LOG_OPTIONS"; + +export interface AuditLogOptions { + onlyErrors?: boolean; +} + +export const EnableAuditLog = (options: AuditLogOptions = {}) => { + return (target: any, key?: string | symbol, descriptor?: any) => { + SetMetadata(IS_AUDIT_LOG_ENABLED, true)(target, key ?? "", descriptor); + SetMetadata(AUDIT_LOG_OPTIONS, options)(target, key ?? "", descriptor); + }; +}; diff --git a/src/@base/audit-logs/schemas/audit-log.schema.ts b/src/@base/audit-logs/schemas/audit-log.schema.ts new file mode 100644 index 0000000..108a7d2 --- /dev/null +++ b/src/@base/audit-logs/schemas/audit-log.schema.ts @@ -0,0 +1,91 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Document } from "mongoose"; + +export type AuditLogDocument = AuditLog & Document; + +/** + * Schema for audit logging of API requests and responses + * Tracks detailed information about HTTP requests, their outcomes, and associated metadata + */ +@Schema({ timestamps: true }) +export class AuditLog { + /** HTTP method of the request (GET, POST, etc.) */ + @Prop({ required: true }) + method: string; + + /** Full URL path of the request */ + @Prop({ required: true }) + url: string; + + /** URL parameters from the request */ + @Prop({ type: Object }) + params: Record; + + /** Query string parameters from the request */ + @Prop({ type: Object }) + query: Record; + + /** Request body data */ + @Prop({ type: Object }) + body: Record; + + /** Client's user agent string */ + @Prop() + userAgent: string; + + /** Client's IP address */ + @Prop({ required: true }) + ipAddress: string; + + /** ID of the authenticated user making the request */ + @Prop() + userId: string; + + /** Response data sent back to the client */ + @Prop({ type: Object }) + response: Record; + + /** Error details if the request failed */ + @Prop({ type: Object }) + error: Record; + + /** Indicates if the request resulted in an error */ + @Prop({ default: false }) + isFailed: boolean; + + /** HTTP status code of the response */ + @Prop() + statusCode: number; + + /** Time taken to process the request in milliseconds */ + @Prop() + duration: number; + + /** Unique identifier for correlating related requests */ + @Prop() + requestId: string; + + /** Request headers */ + @Prop({ type: Object }) + headers: Record; + + /** Origin or referer of the request */ + @Prop() + origin: string; + + /** Session identifier for tracking user sessions */ + @Prop({ required: false }) + sessionId?: string; + + /** Worker identifier for distributed systems */ + @Prop({ required: false }) + workerId?: string; + + /** Timestamp when the log was created */ + createdAt: Date; + + /** Timestamp when the log was last updated */ + updatedAt: Date; +} + +export const AuditLogSchema = SchemaFactory.createForClass(AuditLog); diff --git a/src/@base/audit-logs/types/index.ts b/src/@base/audit-logs/types/index.ts new file mode 100644 index 0000000..aadf8f0 --- /dev/null +++ b/src/@base/audit-logs/types/index.ts @@ -0,0 +1,5 @@ +export const AUDIT_LOGS_QUEUE = "audit-logs"; + +export enum AuditLogQueueJobName { + CREATE_AUDIT_LOG = "create-audit-log", +} diff --git a/src/@base/cache/cache.decorator.ts b/src/@base/cache/cache.decorator.ts new file mode 100644 index 0000000..c316744 --- /dev/null +++ b/src/@base/cache/cache.decorator.ts @@ -0,0 +1,14 @@ +import { SetMetadata } from "@nestjs/common"; + +/** + * @description Cache request options + * @param ttl - Time to live in seconds + */ +export type CacheRequestOptions = { + ttl?: number; +}; + +export const CACHE_REQUEST_KEY = "cacheRequest"; + +export const CacheRequest = (options?: CacheRequestOptions) => + SetMetadata(CACHE_REQUEST_KEY, options); diff --git a/src/@base/cache/cache.interceptor.ts b/src/@base/cache/cache.interceptor.ts new file mode 100644 index 0000000..00255a5 --- /dev/null +++ b/src/@base/cache/cache.interceptor.ts @@ -0,0 +1,99 @@ +import { Request } from "@core/interfaces/request"; +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Observable, tap } from "rxjs"; +import { CACHE_REQUEST_KEY } from "src/@base/cache/cache.decorator"; +import { CacheService } from "src/@base/cache/cache.service"; +import { RedisUtils } from "src/@base/redis/redis.utils"; + +@Injectable() +export class CacheInterceptor implements NestInterceptor { + private readonly logger = new Logger(CacheInterceptor.name); + + constructor( + private readonly reflector: Reflector, + private readonly cacheService: CacheService, + ) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest(); + const controllerClassName = context.getClass().name; + const handlerName = context.getHandler().name; + + const options = this.reflector.get(CACHE_REQUEST_KEY, context.getHandler()); + + if (!options) { + return next.handle(); + } + + if (request.method !== "GET") { + this.logger.warn( + `${controllerClassName}#${handlerName} is not GET request for caching`, + ); + + return next.handle(); + } + + const { ttl = 300 } = options; // Default TTL 5 minutes + const { query, params, worker, method } = request; + + if (!worker) { + // We do not cache requests without worker + return next.handle(); + } + + const cacheKey = RedisUtils.buildKey([ + controllerClassName.trim(), + handlerName.trim(), + worker.id.trim(), + JSON.stringify({ + workerRole: worker.role, + method, + query, + params, + }), + ]); + + try { + const cachedData = await this.cacheService.get(cacheKey); + if (cachedData) { + return new Observable((subscriber) => { + subscriber.next( + typeof cachedData === "string" + ? cachedData + : JSON.parse(cachedData as unknown as string), + ); + subscriber.complete(); + }); + } + + return next.handle().pipe( + tap(async (data) => { + try { + await this.cacheService.setex(cacheKey, data, ttl); + } catch (error) { + this.logger.error( + `Failed to cache data for key ${cacheKey}:`, + error, + ); + } + }), + ); + } catch (error) { + this.logger.error( + `Error in cache interceptor for key ${cacheKey}:`, + error, + ); + return next.handle(); + } + } +} diff --git a/src/@base/cache/cache.module.ts b/src/@base/cache/cache.module.ts new file mode 100644 index 0000000..adace6d --- /dev/null +++ b/src/@base/cache/cache.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { CacheService } from "src/@base/cache/cache.service"; + +@Module({ + imports: [], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/src/@base/cache/cache.service.ts b/src/@base/cache/cache.service.ts new file mode 100644 index 0000000..4a3d859 --- /dev/null +++ b/src/@base/cache/cache.service.ts @@ -0,0 +1,76 @@ +import { env } from "process"; + +import { Injectable, Logger } from "@nestjs/common"; +import { Redis } from "ioredis"; +import { RedisChannels } from "src/@base/redis/channels"; + +type CacheData = { + type: string; + data: any; +}; + +@Injectable() +export class CacheService { + private readonly logger = new Logger(CacheService.name); + + private redis: Redis; + + /** + * Get a Redis client + * @returns Redis client + */ + private _getRedis() { + const client = new Redis(`${env.REDIS_URL}/${RedisChannels.CACHE}`, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + retryStrategy(times) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + }); + + client.on("error", (error) => { + this.logger.error("Redis client error:", error); + }); + + return client; + } + + onModuleInit() { + this.redis = this._getRedis(); + } + + public async get(key: string): Promise { + const data = await this.redis.get(key); + if (!data) { + return null; + } + + const parsedData = JSON.parse(String(data)) as CacheData; + + if (parsedData.type === "string") { + return parsedData.data; + } + + if (parsedData.type === "object") { + return JSON.stringify(parsedData.data); + } + + return null; + } + + public async setex( + key: string, + value: string | object, + ttl: number, + ): Promise { + await this.redis.setex( + key, + ttl, + JSON.stringify({ + type: typeof value, + data: value, + } satisfies CacheData), + ); + } +} diff --git a/src/drizzle/clear.ts b/src/@base/drizzle/clear.ts similarity index 79% rename from src/drizzle/clear.ts rename to src/@base/drizzle/clear.ts index 1f04caf..f66e2f9 100644 --- a/src/drizzle/clear.ts +++ b/src/@base/drizzle/clear.ts @@ -1,15 +1,16 @@ +import env from "@core/env"; import dotenv from "dotenv"; import { sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; dotenv.config({ - path: process.env.NODE_ENV === "test" ? "./.env.test" : "./.env", + path: env.NODE_ENV === "test" ? "./.env.test" : "./.env", }); const clearDatabase = async () => { const pool = new Pool({ - connectionString: process.env.POSTGRESQL_URL, + connectionString: env.POSTGRESQL_URL, }); const db = drizzle(pool); @@ -30,7 +31,7 @@ const clearDatabase = async () => { await Promise.all( tables.map((table) => - db.execute(sql`TRUNCATE TABLE ${sql.identifier(table)};`), + db.execute(sql`TRUNCATE TABLE ${sql.identifier(table as string)};`), ), ); diff --git a/src/@base/drizzle/drizzle-utils.ts b/src/@base/drizzle/drizzle-utils.ts new file mode 100644 index 0000000..b217ce2 --- /dev/null +++ b/src/@base/drizzle/drizzle-utils.ts @@ -0,0 +1,35 @@ +import { FilterCondition, IFilters } from "@core/decorators/filter.decorator"; +import { and, sql } from "drizzle-orm"; +import { PgTable } from "drizzle-orm/pg-core"; + +export class DrizzleUtils { + public static buildFilterConditions( + table: T, + filters?: IFilters, + ) { + if (!filters?.filters?.length) return undefined; + + const conditions = filters.filters + .map((filter) => { + switch (filter.condition) { + case FilterCondition.Equals: + return sql`${table[filter.field]} = ${filter.value}`; + case FilterCondition.NotEquals: + return sql`${table[filter.field]} != ${filter.value}`; + case FilterCondition.Contains: + return sql`${table[filter.field]} ILIKE ${`%${filter.value}%`}`; + case FilterCondition.NotContains: + return sql`${table[filter.field]} NOT ILIKE ${`%${filter.value}%`}`; + case FilterCondition.StartsWith: + return sql`${table[filter.field]} ILIKE ${`${filter.value}%`}`; + case FilterCondition.EndsWith: + return sql`${table[filter.field]} ILIKE ${`%${filter.value}`}`; + default: + return undefined; + } + }) + .filter(Boolean); + + return conditions.length > 1 ? and(...conditions) : conditions[0]; + } +} diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts new file mode 100644 index 0000000..ff4431b --- /dev/null +++ b/src/@base/drizzle/drizzle.module.ts @@ -0,0 +1,103 @@ +import env from "@core/env"; +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +import { PG_CONNECTION } from "../../constants"; + +import * as discounts from "./schema/discounts"; +import * as dishCategories from "./schema/dish-categories"; +import * as dishModifiers from "./schema/dish-modifiers"; +import * as dishes from "./schema/dishes"; +import * as dishesMenus from "./schema/dishes-menus"; +import * as files from "./schema/files"; +import * as general from "./schema/general"; +import * as guests from "./schema/guests"; +import * as manyToMany from "./schema/many-to-many"; +import * as orderDeliveries from "./schema/order-deliveries"; +import * as orderDishes from "./schema/order-dishes"; +import * as orderEnums from "./schema/order-enums"; +import * as orderHistory from "./schema/order-history"; +import * as orderPrechecks from "./schema/order-prechecks"; +import * as orders from "./schema/orders"; +import * as paymentMethods from "./schema/payment-methods"; +import * as restaurantWorkshops from "./schema/restaurant-workshop"; +import * as restaurants from "./schema/restaurants"; +import * as sessions from "./schema/sessions"; +import * as workers from "./schema/workers"; +import * as workshiftEnums from "./schema/workshift-enums"; +import * as workshiftPaymentCategories from "./schema/workshift-payment-category"; +import * as workshiftPayments from "./schema/workshift-payments"; +import * as workshifts from "./schema/workshifts"; + +export const schema = { + ...general, + ...restaurants, + ...sessions, + ...workers, + ...restaurantWorkshops, + ...guests, + ...dishes, + ...dishCategories, + ...manyToMany, + ...files, + ...orderDishes, + ...orderDeliveries, + ...orderEnums, + ...orders, + ...orderPrechecks, + ...orderHistory, + ...paymentMethods, + ...dishModifiers, + ...discounts, + ...dishesMenus, + ...workshifts, + ...workshiftEnums, + ...workshiftPayments, + ...workshiftPaymentCategories, +}; + +export type Schema = typeof schema; +export type DrizzleDatabase = NodePgDatabase; +export type DrizzleTransaction = Parameters< + Parameters[0] +>[0]; + +export interface PgTransactionConfig { + isolationLevel?: + | "read uncommitted" + | "read committed" + | "repeatable read" + | "serializable"; + accessMode?: "read only" | "read write"; + deferrable?: boolean; +} + +@Module({ + providers: [ + { + provide: PG_CONNECTION, + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const connectionString = configService.get("POSTGRESQL_URL"); + const pool = new Pool({ + connectionString, + ssl: + env.NODE_ENV === "production" && + String(connectionString).indexOf("sslmode=required") !== -1 + ? true + : false, + }); + + return drizzle(pool, { + schema, + casing: "snake_case", + // logger: true, + }) as NodePgDatabase; + }, + }, + ], + exports: [PG_CONNECTION], +}) +export class DrizzleModule {} diff --git a/src/drizzle/migrate.ts b/src/@base/drizzle/migrate.ts similarity index 89% rename from src/drizzle/migrate.ts rename to src/@base/drizzle/migrate.ts index 288d9a9..7516e9a 100644 --- a/src/drizzle/migrate.ts +++ b/src/@base/drizzle/migrate.ts @@ -1,3 +1,4 @@ +import env from "@core/env"; import dotenv from "dotenv"; import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; @@ -7,7 +8,7 @@ dotenv.config(); export async function startMigration() { const pool = new Pool({ - connectionString: process.env.POSTGRESQL_URL, + connectionString: env.POSTGRESQL_URL, }); const db = drizzle(pool); diff --git a/src/drizzle/migrations/0000_fantastic_nightmare.sql b/src/@base/drizzle/migrations/0000_amazing_enchantress.sql similarity index 77% rename from src/drizzle/migrations/0000_fantastic_nightmare.sql rename to src/@base/drizzle/migrations/0000_amazing_enchantress.sql index e3a74b0..56ca542 100644 --- a/src/drizzle/migrations/0000_fantastic_nightmare.sql +++ b/src/@base/drizzle/migrations/0000_amazing_enchantress.sql @@ -1,10 +1,5 @@ -DO $$ BEGIN - CREATE TYPE "workerRoleEnum" AS ENUM('SYSTEM_ADMIN', 'CHIEF_ADMIN', 'ADMIN', 'KITCHENER', 'WAITER', 'CASHIER', 'DISPATCHER', 'COURIER'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "restaurantHours" ( +CREATE TYPE "public"."workerRoleEnum" AS ENUM('SYSTEM_ADMIN', 'CHIEF_ADMIN', 'ADMIN', 'KITCHENER', 'WAITER', 'CASHIER', 'DISPATCHER', 'COURIER');--> statement-breakpoint +CREATE TABLE "restaurantHours" ( "id" uuid DEFAULT gen_random_uuid(), "restaurantId" uuid NOT NULL, "dayOfWeek" text NOT NULL, @@ -15,7 +10,7 @@ CREATE TABLE IF NOT EXISTS "restaurantHours" ( "updatedAt" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "restaurants" ( +CREATE TABLE "restaurants" ( "id" uuid DEFAULT gen_random_uuid(), "name" text NOT NULL, "legalEntity" text, @@ -27,7 +22,7 @@ CREATE TABLE IF NOT EXISTS "restaurants" ( "updatedAt" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "workers" ( +CREATE TABLE "workers" ( "id" uuid DEFAULT gen_random_uuid(), "name" text, "restaurantId" uuid, @@ -43,7 +38,7 @@ CREATE TABLE IF NOT EXISTS "workers" ( CONSTRAINT "workers_login_unique" UNIQUE("login") ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "sessions" ( +CREATE TABLE "sessions" ( "id" uuid DEFAULT gen_random_uuid(), "workerId" uuid NOT NULL, "httpAgent" text, diff --git a/src/drizzle/migrations/meta/0000_snapshot.json b/src/@base/drizzle/migrations/meta/0000_snapshot.json similarity index 88% rename from src/drizzle/migrations/meta/0000_snapshot.json rename to src/@base/drizzle/migrations/meta/0000_snapshot.json index 60ca768..aeeb5c6 100644 --- a/src/drizzle/migrations/meta/0000_snapshot.json +++ b/src/@base/drizzle/migrations/meta/0000_snapshot.json @@ -1,10 +1,10 @@ { - "id": "13695bad-8e56-4c47-a933-914b2035a08c", + "id": "a1d8b137-af18-44cb-a268-e7657be61e76", "prevId": "00000000-0000-0000-0000-000000000000", - "version": "5", - "dialect": "pg", + "version": "7", + "dialect": "postgresql", "tables": { - "restaurantHours": { + "public.restaurantHours": { "name": "restaurantHours", "schema": "", "columns": { @@ -64,9 +64,12 @@ "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {} + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "restaurants": { + "public.restaurants": { "name": "restaurants", "schema": "", "columns": { @@ -132,9 +135,12 @@ "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {} + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "workers": { + "public.workers": { "name": "workers", "schema": "", "columns": { @@ -166,6 +172,7 @@ "role": { "name": "role", "type": "workerRoleEnum", + "typeSchema": "public", "primaryKey": false, "notNull": true }, @@ -226,9 +233,12 @@ "login" ] } - } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "sessions": { + "public.sessions": { "name": "sessions", "schema": "", "columns": { @@ -295,25 +305,33 @@ "token" ] } - } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": { - "workerRoleEnum": { + "public.workerRoleEnum": { "name": "workerRoleEnum", - "values": { - "SYSTEM_ADMIN": "SYSTEM_ADMIN", - "CHIEF_ADMIN": "CHIEF_ADMIN", - "ADMIN": "ADMIN", - "KITCHENER": "KITCHENER", - "WAITER": "WAITER", - "CASHIER": "CASHIER", - "DISPATCHER": "DISPATCHER", - "COURIER": "COURIER" - } + "schema": "public", + "values": [ + "SYSTEM_ADMIN", + "CHIEF_ADMIN", + "ADMIN", + "KITCHENER", + "WAITER", + "CASHIER", + "DISPATCHER", + "COURIER" + ] } }, "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, "_meta": { "columns": {}, "schemas": {}, diff --git a/src/@base/drizzle/migrations/meta/_journal.json b/src/@base/drizzle/migrations/meta/_journal.json new file mode 100644 index 0000000..e3fbd4c --- /dev/null +++ b/src/@base/drizzle/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1735886695760, + "tag": "0000_amazing_enchantress", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/@base/drizzle/schema/discounts.ts b/src/@base/drizzle/schema/discounts.ts new file mode 100644 index 0000000..a464ddf --- /dev/null +++ b/src/@base/drizzle/schema/discounts.ts @@ -0,0 +1,185 @@ +import { dishCategories } from "@postgress-db/schema/dish-categories"; +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; +import { dayOfWeekEnum } from "@postgress-db/schema/general"; +import { guests } from "@postgress-db/schema/guests"; +import { orderFromEnum, orderTypeEnum } from "@postgress-db/schema/order-enums"; +import { orders } from "@postgress-db/schema/orders"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { relations } from "drizzle-orm"; +import { + boolean, + index, + integer, + pgTable, + primaryKey, + text, + time, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +export const discounts = pgTable("discounts", { + // Primary key // + id: uuid("id").defaultRandom().primaryKey(), + + // Name of the discount // + name: text("name").notNull(), + + // Description of the discount // + description: text("description").default(""), + + // Info // + percent: integer("percent").notNull().default(0), + + // Basic conditions // + orderFroms: orderFromEnum("order_froms").array().notNull(), + orderTypes: orderTypeEnum("order_types").array().notNull(), + daysOfWeek: dayOfWeekEnum("days_of_week").array().notNull(), + + // Advanced conditions // + promocode: text("promocode"), + applyOnlyByPromocode: boolean("apply_only_by_promocode") + .notNull() + .default(false), + applyOnlyAtFirstOrder: boolean("apply_only_at_first_order") + .notNull() + .default(false), + + // Boolean flags // + isEnabled: boolean("is_enabled").notNull().default(true), + + // Valid time // + startTime: time("start_time", { withTimezone: false }), + endTime: time("end_time", { withTimezone: false }), + activeFrom: timestamp("active_from", { + withTimezone: true, + }).notNull(), + activeTo: timestamp("active_to", { + withTimezone: true, + }).notNull(), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +export type IDiscount = typeof discounts.$inferSelect; + +export const discountRelations = relations(discounts, ({ many }) => ({ + connections: many(discountsConnections), + discountsToOrders: many(discountsToOrders), + discountsToGuests: many(discountsToGuests), +})); + +export const discountsConnections = pgTable( + "discount_connections", + { + discountId: uuid("discount_id").notNull(), + dishesMenuId: uuid("dishes_menu_id").notNull(), + restaurantId: uuid("restaurant_id").notNull(), + dishCategoryId: uuid("dish_category_id").notNull(), + }, + (t) => [ + primaryKey({ + columns: [t.discountId, t.dishesMenuId, t.restaurantId, t.dishCategoryId], + }), + index("discount_connections_discount_id_idx").on(t.discountId), + index("discount_connections_dishes_menu_id_idx").on(t.dishesMenuId), + index("discount_connections_restaurant_id_idx").on(t.restaurantId), + index("discount_connections_dish_category_id_idx").on(t.dishCategoryId), + ], +); + +export type IDiscountConnection = typeof discountsConnections.$inferSelect; + +export const discountConnectionsRelations = relations( + discountsConnections, + ({ one }) => ({ + discount: one(discounts, { + fields: [discountsConnections.discountId], + references: [discounts.id], + }), + dishesMenu: one(dishesMenus, { + fields: [discountsConnections.dishesMenuId], + references: [dishesMenus.id], + }), + restaurant: one(restaurants, { + fields: [discountsConnections.restaurantId], + references: [restaurants.id], + }), + dishCategory: one(dishCategories, { + fields: [discountsConnections.dishCategoryId], + references: [dishCategories.id], + }), + }), +); + +export const discountsToOrders = pgTable( + "discounts_to_orders", + { + discountId: uuid("discount_id").notNull(), + orderId: uuid("order_id").notNull(), + }, + (t) => [ + primaryKey({ columns: [t.discountId, t.orderId] }), + index("discounts_to_orders_order_id_idx").on(t.orderId), + ], +); + +export type IDiscountToOrder = typeof discountsToOrders.$inferSelect; + +export const discountToOrderRelations = relations( + discountsToOrders, + ({ one }) => ({ + discount: one(discounts, { + fields: [discountsToOrders.discountId], + references: [discounts.id], + }), + order: one(orders, { + fields: [discountsToOrders.orderId], + references: [orders.id], + }), + }), +); + +export const discountsToGuests = pgTable( + "discounts_to_guests", + { + discountId: uuid("discount_id").notNull(), + guestId: uuid("guest_id").notNull(), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + }, + (t) => [ + primaryKey({ columns: [t.discountId, t.guestId] }), + index("discounts_to_guests_guest_id_idx").on(t.guestId), + ], +); + +export type IDiscountToGuest = typeof discountsToGuests.$inferSelect; + +export const discountToGuestRelations = relations( + discountsToGuests, + ({ one }) => ({ + discount: one(discounts, { + fields: [discountsToGuests.discountId], + references: [discounts.id], + }), + guest: one(guests, { + fields: [discountsToGuests.guestId], + references: [guests.id], + }), + }), +); diff --git a/src/@base/drizzle/schema/dish-categories.ts b/src/@base/drizzle/schema/dish-categories.ts new file mode 100644 index 0000000..f989935 --- /dev/null +++ b/src/@base/drizzle/schema/dish-categories.ts @@ -0,0 +1,61 @@ +import { dishesToDishCategories } from "@postgress-db/schema/dishes"; +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; +import { relations } from "drizzle-orm"; +import { + boolean, + index, + pgTable, + serial, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +export const dishCategories = pgTable( + "dish_categories", + { + id: uuid("id").defaultRandom().primaryKey(), + + // Category belongs to a menu // + menuId: uuid("menu_id").notNull(), + + // Name of the category // + name: text("name").notNull().default(""), + + // Will category be visible for workers // + showForWorkers: boolean("show_for_workers").notNull().default(false), + + // Will category be visible for guests at site and in app // + showForGuests: boolean("show_for_guests").notNull().default(false), + + // Sorting index in the admin menu // + sortIndex: serial("sort_index").notNull(), + + // Default timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [index("dish_categories_menu_id_idx").on(t.menuId)], +); + +export type IDishCategory = typeof dishCategories.$inferSelect; + +export const dishCategoryRelations = relations( + dishCategories, + ({ one, many }) => ({ + menu: one(dishesMenus, { + fields: [dishCategories.menuId], + references: [dishesMenus.id], + }), + dishesToDishCategories: many(dishesToDishCategories), + }), +); diff --git a/src/@base/drizzle/schema/dish-modifiers.ts b/src/@base/drizzle/schema/dish-modifiers.ts new file mode 100644 index 0000000..5d1f445 --- /dev/null +++ b/src/@base/drizzle/schema/dish-modifiers.ts @@ -0,0 +1,80 @@ +import { orderDishes } from "@postgress-db/schema/order-dishes"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { relations } from "drizzle-orm"; +import { + boolean, + pgTable, + primaryKey, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +export const dishModifiers = pgTable("dish_modifiers", { + id: uuid("id").defaultRandom().primaryKey(), + + // Modifier data // + name: text("name").notNull(), + + // Modifiers should be linked to a restaurant // + restaurantId: uuid("restaurant_id").notNull(), + + // Boolean fields // + isActive: boolean("is_active").notNull().default(true), + isRemoved: boolean("is_removed").notNull().default(false), + + // Default timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + removedAt: timestamp("removed_at", { + withTimezone: true, + }), +}); + +export type IDishModifier = typeof dishModifiers.$inferSelect; + +export const dishModifiersToOrderDishes = pgTable( + "dish_modifiers_to_order_dishes", + { + dishModifierId: uuid("dish_modifier_id").notNull(), + orderDishId: uuid("order_dish_id").notNull(), + }, + (t) => [primaryKey({ columns: [t.dishModifierId, t.orderDishId] })], +); + +export type IDishModifiersToOrderDishes = + typeof dishModifiersToOrderDishes.$inferSelect; + +export const dishModifiersToOrderDishesRelations = relations( + dishModifiersToOrderDishes, + ({ one }) => ({ + dishModifier: one(dishModifiers, { + fields: [dishModifiersToOrderDishes.dishModifierId], + references: [dishModifiers.id], + }), + orderDish: one(orderDishes, { + fields: [dishModifiersToOrderDishes.orderDishId], + references: [orderDishes.id], + }), + }), +); + +export const dishModifierRelations = relations( + dishModifiers, + ({ one, many }) => ({ + restaurant: one(restaurants, { + fields: [dishModifiers.restaurantId], + references: [restaurants.id], + }), + dishModifiersToOrderDishes: many(dishModifiersToOrderDishes), + }), +); diff --git a/src/@base/drizzle/schema/dishes-menus.ts b/src/@base/drizzle/schema/dishes-menus.ts new file mode 100644 index 0000000..b1d4195 --- /dev/null +++ b/src/@base/drizzle/schema/dishes-menus.ts @@ -0,0 +1,79 @@ +import { dishCategories } from "@postgress-db/schema/dish-categories"; +import { dishes } from "@postgress-db/schema/dishes"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { workers } from "@postgress-db/schema/workers"; +import { relations } from "drizzle-orm"; +import { + boolean, + pgTable, + primaryKey, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +export const dishesMenus = pgTable("dishes_menus", { + id: uuid("id").defaultRandom().primaryKey(), + + // Name of the menu with dishes // + name: text("name").notNull().default(""), + + // Owner of the menu // + ownerId: uuid("owner_id").notNull(), + + // Boolean flags // + isRemoved: boolean("is_removed").notNull().default(false), + + // Default timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + removedAt: timestamp("removed_at", { withTimezone: true }), +}); + +export type IDishesMenu = typeof dishesMenus.$inferSelect; + +export const dishesMenusToRestaurants = pgTable( + "dishes_menus_to_restaurants", + { + restaurantId: uuid("restaurant_id").notNull(), + dishesMenuId: uuid("dishes_menu_id").notNull(), + }, + (t) => [ + primaryKey({ + columns: [t.restaurantId, t.dishesMenuId], + }), + ], +); + +export const dishesMenusToRestaurantsRelations = relations( + dishesMenusToRestaurants, + ({ one }) => ({ + restaurant: one(restaurants, { + fields: [dishesMenusToRestaurants.restaurantId], + references: [restaurants.id], + }), + dishesMenu: one(dishesMenus, { + fields: [dishesMenusToRestaurants.dishesMenuId], + references: [dishesMenus.id], + }), + }), +); + +export const dishesMenusRelations = relations(dishesMenus, ({ one, many }) => ({ + dishes: many(dishes), + dishesMenusToRestaurants: many(dishesMenusToRestaurants), + dishCategories: many(dishCategories), + owner: one(workers, { + fields: [dishesMenus.ownerId], + references: [workers.id], + }), +})); diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts new file mode 100644 index 0000000..e3747a6 --- /dev/null +++ b/src/@base/drizzle/schema/dishes.ts @@ -0,0 +1,206 @@ +import { dishCategories } from "@postgress-db/schema/dish-categories"; +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; +import { currencyEnum } from "@postgress-db/schema/general"; +import { dishesToImages } from "@postgress-db/schema/many-to-many"; +import { orderDishes } from "@postgress-db/schema/order-dishes"; +import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { relations } from "drizzle-orm"; +import { + boolean, + decimal, + index, + integer, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const weightMeasureEnum = pgEnum("weight_measure_enum", [ + "grams", + "milliliters", +]); + +export const ZodWeightMeasureEnum = z.enum(weightMeasureEnum.enumValues); + +export type WeightMeasureEnum = typeof ZodWeightMeasureEnum._type; + +export const dishes = pgTable( + "dishes", + { + id: uuid("id").defaultRandom().primaryKey(), + + // Menu // + menuId: uuid("menu_id"), + + // Name of the dish // + name: text("name").notNull().default(""), + + // Note that was available only for persons that can update dish // + note: text("note").notNull().default(""), + + // How much time is needed for cooking // + cookingTimeInMin: integer("cooking_time_in_min").notNull().default(0), + + // How many pcs in one item (for example: 6 hinkali per one item) // + amountPerItem: integer("amount_per_item").notNull().default(1), + + // Weight of the dish // + weight: integer("weight").notNull().default(0), + weightMeasure: weightMeasureEnum("weight_measure") + .notNull() + .default("grams"), + + // Label printing // + isLabelPrintingEnabled: boolean("is_label_printing_enabled") + .notNull() + .default(false), + printLabelEveryItem: integer("print_label_every_item").notNull().default(0), + + // Publishing booleans // + isPublishedInApp: boolean("is_published_in_app").notNull().default(false), + isPublishedAtSite: boolean("is_published_at_site").notNull().default(false), + + // Default timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + index("dishes_menu_id_idx").on(t.menuId), + index("dishes_is_published_in_app_idx").on(t.isPublishedInApp), + index("dishes_is_published_at_site_idx").on(t.isPublishedAtSite), + ], +); + +export type IDish = typeof dishes.$inferSelect; + +export const dishesToRestaurants = pgTable( + "dishes_to_restaurants", + { + dishId: uuid("dish_id").notNull(), + restaurantId: uuid("restaurant_id").notNull(), + price: decimal("price", { precision: 10, scale: 2 }).notNull().default("0"), + currency: currencyEnum("currency").notNull().default("EUR"), + isInStopList: boolean("is_in_stop_list").notNull().default(false), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + primaryKey({ + columns: [t.dishId, t.restaurantId], + }), + ], +); + +export type IDishToRestaurant = typeof dishesToRestaurants.$inferSelect; + +export const dishesToRestaurantsRelations = relations( + dishesToRestaurants, + ({ one }) => ({ + dish: one(dishes, { + fields: [dishesToRestaurants.dishId], + references: [dishes.id], + }), + restaurant: one(restaurants, { + fields: [dishesToRestaurants.restaurantId], + references: [restaurants.id], + }), + }), +); + +export const dishesToWorkshops = pgTable( + "dishes_to_workshops", + { + dishId: uuid("dish_id").notNull(), + workshopId: uuid("workshop_id").notNull(), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + }, + (t) => [ + primaryKey({ + columns: [t.dishId, t.workshopId], + }), + ], +); + +export type IDishToWorkshop = typeof dishesToWorkshops.$inferSelect; + +export const dishesToWorkshopsRelations = relations( + dishesToWorkshops, + ({ one }) => ({ + dish: one(dishes, { + fields: [dishesToWorkshops.dishId], + references: [dishes.id], + }), + workshop: one(restaurantWorkshops, { + fields: [dishesToWorkshops.workshopId], + references: [restaurantWorkshops.id], + }), + }), +); + +export const dishesToDishCategories = pgTable( + "dishes_to_dish_categories", + { + dishId: uuid("dish_id").notNull(), + dishCategoryId: uuid("dish_category_id").notNull(), + }, + (t) => [ + primaryKey({ + columns: [t.dishId, t.dishCategoryId], + }), + ], +); + +export type IDishToDishCategories = typeof dishesToDishCategories.$inferSelect; + +export const dishesToDishCategoriesRelations = relations( + dishesToDishCategories, + ({ one }) => ({ + dish: one(dishes, { + fields: [dishesToDishCategories.dishId], + references: [dishes.id], + }), + dishCategory: one(dishCategories, { + fields: [dishesToDishCategories.dishCategoryId], + references: [dishCategories.id], + }), + }), +); + +export const dishRelations = relations(dishes, ({ one, many }) => ({ + dishesToImages: many(dishesToImages), + dishesToWorkshops: many(dishesToWorkshops), + dishesToRestaurants: many(dishesToRestaurants), + orderDishes: many(orderDishes), + menu: one(dishesMenus, { + fields: [dishes.menuId], + references: [dishesMenus.id], + }), + dishesToDishCategories: many(dishesToDishCategories), +})); diff --git a/src/@base/drizzle/schema/files.ts b/src/@base/drizzle/schema/files.ts new file mode 100644 index 0000000..dfcb280 --- /dev/null +++ b/src/@base/drizzle/schema/files.ts @@ -0,0 +1,47 @@ +import { dishesToImages } from "@postgress-db/schema/many-to-many"; +import { relations } from "drizzle-orm"; +import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const files = pgTable("files", { + id: uuid("id").defaultRandom().primaryKey(), + + // File group id // + groupId: uuid("group_id"), + + // Original name of the file // + originalName: text("original_name").notNull(), + + // Mime type of the file // + mimeType: text("mime_type").notNull(), + + // Extension of the file // + extension: text("extension").notNull(), + + // Bucket name // + bucketName: text("bucket_name").notNull(), + + // Region of the file // + region: text("region").notNull(), + + // Endpoint of the file // + endpoint: text("endpoint").notNull(), + + // Size of the file in bytes // + size: integer("size").notNull().default(0), + + // Uploaded by user id // + uploadedByUserId: uuid("uploaded_by_user_id"), + + // Created at // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), +}); + +export type IFile = typeof files.$inferSelect; + +export const fileRelations = relations(files, ({ many }) => ({ + dishesToImages: many(dishesToImages), +})); diff --git a/src/@base/drizzle/schema/general.ts b/src/@base/drizzle/schema/general.ts new file mode 100644 index 0000000..b12b525 --- /dev/null +++ b/src/@base/drizzle/schema/general.ts @@ -0,0 +1,28 @@ +import { pgEnum } from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const dayOfWeekEnum = pgEnum("day_of_week", [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +]); + +export const ZodDayOfWeekEnum = z.enum(dayOfWeekEnum.enumValues); + +export const currencyEnum = pgEnum("currency", ["EUR", "USD", "RUB"]); + +export const ZodCurrency = z.enum(currencyEnum.enumValues); + +export type ZodCurrencyEnum = typeof ZodCurrency._type; + +export type ICurrency = (typeof currencyEnum.enumValues)[number]; + +export const localeEnum = pgEnum("locale", ["en", "ru", "ee"]); + +export const ZodLocaleEnum = z.enum(localeEnum.enumValues); + +export type ZodLocaleEnum = typeof ZodLocaleEnum._type; diff --git a/src/@base/drizzle/schema/guests.ts b/src/@base/drizzle/schema/guests.ts new file mode 100644 index 0000000..1322e95 --- /dev/null +++ b/src/@base/drizzle/schema/guests.ts @@ -0,0 +1,34 @@ +import { discountsToGuests } from "@postgress-db/schema/discounts"; +import { orders } from "@postgress-db/schema/orders"; +import { relations } from "drizzle-orm"; +import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const guests = pgTable("guests", { + id: uuid("id").defaultRandom().primaryKey(), + name: text("name").notNull().default(""), + phone: text("phone").unique().notNull(), + email: text("email").unique(), + bonusBalance: integer("bonus_balance").notNull().default(0), + lastVisitAt: timestamp("last_visit_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), +}); + +export type IGuest = typeof guests.$inferSelect; + +export const guestRelations = relations(guests, ({ many }) => ({ + orders: many(orders), + discountsToGuests: many(discountsToGuests), +})); diff --git a/src/@base/drizzle/schema/many-to-many.ts b/src/@base/drizzle/schema/many-to-many.ts new file mode 100644 index 0000000..ca15915 --- /dev/null +++ b/src/@base/drizzle/schema/many-to-many.ts @@ -0,0 +1,35 @@ +import { dishes } from "@postgress-db/schema/dishes"; +import { files } from "@postgress-db/schema/files"; +import { relations } from "drizzle-orm"; +import { integer, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core"; + +// ----------------------------------- // +// Dishes to images relation // +// ----------------------------------- // +export const dishesToImages = pgTable( + "dishes_to_images", + { + dishId: uuid("dish_id").notNull(), + imageFileId: uuid("image_file_id").notNull(), + alt: text("alt").notNull().default(""), + sortIndex: integer("sort_index").notNull().default(0), + }, + (t) => [ + primaryKey({ + columns: [t.dishId, t.imageFileId], + }), + ], +); + +export type IDishesToImages = typeof dishesToImages.$inferSelect; + +export const dishesToImagesRelations = relations(dishesToImages, ({ one }) => ({ + dish: one(dishes, { + fields: [dishesToImages.dishId], + references: [dishes.id], + }), + imageFile: one(files, { + fields: [dishesToImages.imageFileId], + references: [files.id], + }), +})); diff --git a/src/@base/drizzle/schema/order-deliveries.ts b/src/@base/drizzle/schema/order-deliveries.ts new file mode 100644 index 0000000..dc78547 --- /dev/null +++ b/src/@base/drizzle/schema/order-deliveries.ts @@ -0,0 +1,72 @@ +import { orders } from "@postgress-db/schema/orders"; +import { workers } from "@postgress-db/schema/workers"; +import { relations } from "drizzle-orm"; +import { + decimal, + numeric, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const orderDeliveryStatusEnum = pgEnum("order_delivery_status_enum", [ + "pending", + "dispatched", + "delivered", +]); + +export const ZodOrderDeliveryStatusEnum = z.enum( + orderDeliveryStatusEnum.enumValues, +); + +export type OrderDeliveryStatusEnum = typeof ZodOrderDeliveryStatusEnum._type; + +export const orderDeliveries = pgTable("order_deliveries", { + id: uuid("id").defaultRandom().primaryKey(), + + // Relation fields // + orderId: uuid("order_id").notNull(), + workerId: uuid("worker_id"), + + // Data // + status: orderDeliveryStatusEnum("status").notNull(), + address: text("address").notNull(), + note: text("note"), + latitude: numeric("latitude").notNull(), + longitude: numeric("longitude").notNull(), + price: decimal("price", { precision: 10, scale: 2 }).notNull(), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + dispatchedAt: timestamp("dispatched_at", { withTimezone: true }), + estimatedDeliveryAt: timestamp("estimated_delivery_at", { + withTimezone: true, + }), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), +}); + +export type IOrderDelivery = typeof orderDeliveries.$inferSelect; + +export const orderDeliveryRelations = relations(orderDeliveries, ({ one }) => ({ + order: one(orders, { + fields: [orderDeliveries.orderId], + references: [orders.id], + }), + worker: one(workers, { + fields: [orderDeliveries.workerId], + references: [workers.id], + }), +})); diff --git a/src/@base/drizzle/schema/order-dishes.ts b/src/@base/drizzle/schema/order-dishes.ts new file mode 100644 index 0000000..e14a403 --- /dev/null +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -0,0 +1,153 @@ +import { dishModifiersToOrderDishes } from "@postgress-db/schema/dish-modifiers"; +import { dishes } from "@postgress-db/schema/dishes"; +import { orders } from "@postgress-db/schema/orders"; +import { workers } from "@postgress-db/schema/workers"; +import { relations } from "drizzle-orm"; +import { + boolean, + decimal, + index, + integer, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const orderDishStatusEnum = pgEnum("order_dish_status_enum", [ + "pending", + "cooking", + "ready", + "completed", +]); + +export const ZodOrderDishStatusEnum = z.enum(orderDishStatusEnum.enumValues); + +export type OrderDishStatusEnum = typeof ZodOrderDishStatusEnum._type; + +export const orderDishes = pgTable( + "order_dishes", + { + id: uuid("id").defaultRandom().primaryKey(), + + // Relation fields // + orderId: uuid("order_id").notNull(), + dishId: uuid("dish_id").notNull(), + discountId: uuid("discount_id"), + surchargeId: uuid("surcharge_id"), + + // Data // + name: text("name").notNull(), + status: orderDishStatusEnum("status").notNull(), + + // Quantity // + quantity: integer("quantity").notNull(), + quantityReturned: integer("quantity_returned").notNull().default(0), + + // Price info // + price: decimal("price", { precision: 10, scale: 2 }).notNull(), + discountPercent: decimal("discount_percent", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + discountAmount: decimal("discount_amount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargePercent: decimal("surcharge_percent", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargeAmount: decimal("surcharge_amount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + finalPrice: decimal("final_price", { precision: 10, scale: 2 }).notNull(), + + // Booleans flags // + isRemoved: boolean("is_removed").notNull().default(false), + isAdditional: boolean("is_additional").notNull().default(false), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + cookingAt: timestamp("cooking_at", { withTimezone: true }), + readyAt: timestamp("ready_at", { withTimezone: true }), + removedAt: timestamp("removed_at", { withTimezone: true }), + }, + (table) => [ + index("order_dishes_order_id_idx").on(table.orderId), + index("order_dishes_status_idx").on(table.status), + index("order_dishes_is_removed_idx").on(table.isRemoved), + ], +); + +export type IOrderDish = typeof orderDishes.$inferSelect; + +export const orderDishRelations = relations(orderDishes, ({ one, many }) => ({ + order: one(orders, { + fields: [orderDishes.orderId], + references: [orders.id], + }), + dish: one(dishes, { + fields: [orderDishes.dishId], + references: [dishes.id], + }), + dishModifiersToOrderDishes: many(dishModifiersToOrderDishes), + returnments: many(orderDishesReturnments), +})); + +export const orderDishesReturnments = pgTable("order_dishes_returnments", { + id: uuid("id").defaultRandom().primaryKey(), + + // Relations // + orderDishId: uuid("order_dish_id").notNull(), + workerId: uuid("worker_id").notNull(), + + // Returned quantity // + quantity: integer("quantity").notNull(), + + // Reason // + reason: text("reason").notNull(), + + // Flags // + isDoneAfterPrecheck: boolean("is_done_after_precheck") + .notNull() + .default(false), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +export type IOrderDishReturnment = typeof orderDishesReturnments.$inferSelect; + +export const orderDishReturnmentRelations = relations( + orderDishesReturnments, + ({ one }) => ({ + orderDish: one(orderDishes, { + fields: [orderDishesReturnments.orderDishId], + references: [orderDishes.id], + }), + worker: one(workers, { + fields: [orderDishesReturnments.workerId], + references: [workers.id], + }), + }), +); diff --git a/src/@base/drizzle/schema/order-enums.ts b/src/@base/drizzle/schema/order-enums.ts new file mode 100644 index 0000000..08240e0 --- /dev/null +++ b/src/@base/drizzle/schema/order-enums.ts @@ -0,0 +1,48 @@ +import { pgEnum } from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const orderTypeEnum = pgEnum("order_type_enum", [ + "hall", + "banquet", + "takeaway", + "delivery", +]); + +export const ZodOrderTypeEnum = z.enum(orderTypeEnum.enumValues); + +export type OrderTypeEnum = typeof ZodOrderTypeEnum._type; + +export const orderFromEnum = pgEnum("order_from_enum", [ + "app", + "website", + "internal", +]); + +export const ZodOrderFromEnum = z.enum(orderFromEnum.enumValues); + +export type OrderFromEnum = typeof ZodOrderFromEnum._type; + +export const orderStatusEnum = pgEnum("order_status_enum", [ + "pending", + "cooking", + "ready", + "delivering", + "paid", + "completed", + "cancelled", +]); + +export const ZodOrderStatusEnum = z.enum(orderStatusEnum.enumValues); + +export type OrderStatusEnum = typeof ZodOrderStatusEnum._type; + +export const orderHistoryTypeEnum = pgEnum("order_history_type_enum", [ + "created", + "precheck", + "sent_to_kitchen", + "dishes_ready", + "discounts_enabled", + "discounts_disabled", +]); + +export const ZodOrderHistoryTypeEnum = z.enum(orderHistoryTypeEnum.enumValues); diff --git a/src/@base/drizzle/schema/order-history.ts b/src/@base/drizzle/schema/order-history.ts new file mode 100644 index 0000000..861a129 --- /dev/null +++ b/src/@base/drizzle/schema/order-history.ts @@ -0,0 +1,33 @@ +import { orderHistoryTypeEnum } from "@postgress-db/schema/order-enums"; +import { orders } from "@postgress-db/schema/orders"; +import { workers } from "@postgress-db/schema/workers"; +import { relations } from "drizzle-orm"; +import { pgTable, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const orderHistoryRecords = pgTable("order_history_records", { + id: uuid("id").defaultRandom().primaryKey(), + orderId: uuid("order_id").notNull(), + workerId: uuid("worker_id"), + type: orderHistoryTypeEnum("type").notNull(), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), +}); + +export type IOrderHistoryRecord = typeof orderHistoryRecords.$inferSelect; + +export const orderHistoryRecordsRelations = relations( + orderHistoryRecords, + ({ one }) => ({ + order: one(orders, { + fields: [orderHistoryRecords.orderId], + references: [orders.id], + }), + worker: one(workers, { + fields: [orderHistoryRecords.workerId], + references: [workers.id], + }), + }), +); diff --git a/src/@base/drizzle/schema/order-prechecks.ts b/src/@base/drizzle/schema/order-prechecks.ts new file mode 100644 index 0000000..a697307 --- /dev/null +++ b/src/@base/drizzle/schema/order-prechecks.ts @@ -0,0 +1,81 @@ +import { currencyEnum, localeEnum } from "@postgress-db/schema/general"; +import { orders } from "@postgress-db/schema/orders"; +import { workers } from "@postgress-db/schema/workers"; +import { relations } from "drizzle-orm"; +import { + decimal, + integer, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { orderTypeEnum } from "./order-enums"; + +export const orderPrechecks = pgTable("order_prechecks", { + id: uuid("id").defaultRandom().primaryKey(), + + // Links // + orderId: uuid("order_id").notNull(), + + // Worker who did the precheck // + workerId: uuid("worker_id").notNull(), + + // Fields // + type: orderTypeEnum("type").notNull(), + legalEntity: text("legal_entity").notNull(), + locale: localeEnum("locale").notNull(), + currency: currencyEnum("currency").notNull(), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), +}); + +export type IOrderPrecheck = typeof orderPrechecks.$inferSelect; + +export const orderPrechecksRelations = relations( + orderPrechecks, + ({ one, many }) => ({ + order: one(orders, { + fields: [orderPrechecks.orderId], + references: [orders.id], + }), + worker: one(workers, { + fields: [orderPrechecks.workerId], + references: [workers.id], + }), + positions: many(orderPrecheckPositions), + }), +); + +export const orderPrecheckPositions = pgTable("order_precheck_positions", { + id: uuid("id").defaultRandom().primaryKey(), + precheckId: uuid("precheck_id").notNull(), + name: text("name").notNull(), + quantity: integer("quantity").notNull(), + price: decimal("price", { precision: 10, scale: 2 }).notNull(), + discountAmount: decimal("discount_amount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargeAmount: decimal("surcharge_amount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + finalPrice: decimal("final_price", { precision: 10, scale: 2 }).notNull(), +}); + +export type IOrderPrecheckPosition = typeof orderPrecheckPositions.$inferSelect; + +export const orderPrecheckPositionsRelations = relations( + orderPrecheckPositions, + ({ one }) => ({ + precheck: one(orderPrechecks, { + fields: [orderPrecheckPositions.precheckId], + references: [orderPrechecks.id], + }), + }), +); diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts new file mode 100644 index 0000000..68582da --- /dev/null +++ b/src/@base/drizzle/schema/orders.ts @@ -0,0 +1,135 @@ +import { discountsToOrders } from "@postgress-db/schema/discounts"; +import { currencyEnum } from "@postgress-db/schema/general"; +import { guests } from "@postgress-db/schema/guests"; +import { orderDeliveries } from "@postgress-db/schema/order-deliveries"; +import { orderDishes } from "@postgress-db/schema/order-dishes"; +import { orderHistoryRecords } from "@postgress-db/schema/order-history"; +import { orderPrechecks } from "@postgress-db/schema/order-prechecks"; +import { paymentMethods } from "@postgress-db/schema/payment-methods"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { relations } from "drizzle-orm"; +import { + boolean, + decimal, + index, + integer, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { orderFromEnum, orderStatusEnum, orderTypeEnum } from "./order-enums"; + +export const orderNumberBroneering = pgTable("order_number_broneering", { + id: uuid("id").defaultRandom().primaryKey(), + number: text("number").notNull(), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), +}); + +export const orders = pgTable( + "orders", + { + id: uuid("id").defaultRandom().primaryKey(), + + // Links // + guestId: uuid("guest_id"), + discountsGuestId: uuid("discounts_guest_id"), + restaurantId: uuid("restaurant_id"), + paymentMethodId: uuid("payment_method_id"), + + // Order number // + number: text("number").notNull(), + tableNumber: text("table_number"), + + // Order type // + type: orderTypeEnum("type").notNull(), + status: orderStatusEnum("status").notNull(), + currency: currencyEnum("currency").notNull(), + from: orderFromEnum("from").notNull(), + + // Note from the admins // + note: text("note"), + + // Guest information // + guestName: text("guest_name"), + guestPhone: text("guest_phone"), + guestsAmount: integer("guests_amount"), + + // Price info // + subtotal: decimal("subtotal", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + discountAmount: decimal("discount_amount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargeAmount: decimal("surcharge_amount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + bonusUsed: decimal("bonusUsed", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + total: decimal("total", { precision: 10, scale: 2 }).notNull().default("0"), + + // Booleans flags // + applyDiscounts: boolean("apply_discounts").notNull().default(false), + isHiddenForGuest: boolean("is_hidden_for_guest").notNull().default(false), + isRemoved: boolean("is_removed").notNull().default(false), + isArchived: boolean("is_archived").notNull().default(false), + + // Default timestamps + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + cookingAt: timestamp("cooking_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + removedAt: timestamp("removed_at", { withTimezone: true }), + delayedTo: timestamp("delayed_to", { withTimezone: true }), + }, + (table) => [ + index("orders_restaurant_id_idx").on(table.restaurantId), + index("orders_created_at_idx").on(table.createdAt), + index("orders_is_archived_idx").on(table.isArchived), + index("orders_is_removed_idx").on(table.isRemoved), + index("order_id_and_created_at_idx").on(table.id, table.createdAt), + index("orders_status_idx").on(table.status), + index("orders_delayed_to_idx").on(table.delayedTo), + ], +); + +export type IOrder = typeof orders.$inferSelect; + +export const orderRelations = relations(orders, ({ one, many }) => ({ + paymentMethod: one(paymentMethods, { + fields: [orders.paymentMethodId], + references: [paymentMethods.id], + }), + delivery: one(orderDeliveries, { + fields: [orders.id], + references: [orderDeliveries.orderId], + }), + restaurant: one(restaurants, { + fields: [orders.restaurantId], + references: [restaurants.id], + }), + guest: one(guests, { + fields: [orders.guestId], + references: [guests.id], + }), + orderDishes: many(orderDishes), + prechecks: many(orderPrechecks), + historyRecords: many(orderHistoryRecords), + discountsToOrders: many(discountsToOrders), +})); diff --git a/src/@base/drizzle/schema/payment-methods.ts b/src/@base/drizzle/schema/payment-methods.ts new file mode 100644 index 0000000..171b016 --- /dev/null +++ b/src/@base/drizzle/schema/payment-methods.ts @@ -0,0 +1,71 @@ +import { orders } from "@postgress-db/schema/orders"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { relations } from "drizzle-orm"; +import { + boolean, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +export const paymentMethodTypeEnum = pgEnum("payment_method_type", [ + "YOO_KASSA", + "CUSTOM", +]); + +export const paymentMethodIconEnum = pgEnum("payment_method_icon", [ + "YOO_KASSA", + "CASH", + "CARD", +]); + +export type IPaymentMethodType = + (typeof paymentMethodTypeEnum.enumValues)[number]; + +export const paymentMethods = pgTable("payment_methods", { + id: uuid("id").defaultRandom().primaryKey(), + + // For example "Yoo Kassa" or "Cash"/"Card" // + name: text("name").notNull(), + type: paymentMethodTypeEnum("type").notNull(), + icon: paymentMethodIconEnum("icon").notNull(), + + restaurantId: uuid("restaurant_id").notNull(), + + // For YOO_KASSA // + secretId: text("secret_id"), + secretKey: text("secret_key"), + + // Boolean fields // + isActive: boolean("is_active").notNull().default(false), + isRemoved: boolean("is_removed").notNull().default(false), + + // Default timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + removedAt: timestamp("removed_at", { withTimezone: true }), +}); + +export type IPaymentMethod = typeof paymentMethods.$inferSelect; + +export const paymentMethodRelations = relations( + paymentMethods, + ({ one, many }) => ({ + orders: many(orders), + restaurant: one(restaurants, { + fields: [paymentMethods.restaurantId], + references: [restaurants.id], + }), + }), +); diff --git a/src/@base/drizzle/schema/restaurant-workshop.ts b/src/@base/drizzle/schema/restaurant-workshop.ts new file mode 100644 index 0000000..3784149 --- /dev/null +++ b/src/@base/drizzle/schema/restaurant-workshop.ts @@ -0,0 +1,82 @@ +import { dishesToWorkshops } from "@postgress-db/schema/dishes"; +import { relations } from "drizzle-orm"; +import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +import { restaurants } from "./restaurants"; +import { workers } from "./workers"; + +export const restaurantWorkshops = pgTable("restaurant_workshops", { + // Primary key // + id: uuid("id").defaultRandom().primaryKey(), + + // Restaurant // + restaurantId: uuid("restaurant_id").notNull(), + + // Name of the workshop // + name: text("name").notNull(), + + // Is label printing enabled? // + isLabelPrintingEnabled: boolean("is_label_printing_enabled") + .notNull() + .default(false), + + // Is enabled? // + isEnabled: boolean("is_enabled").notNull().default(true), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +export const restaurantWorkshopRelations = relations( + restaurantWorkshops, + ({ one, many }) => ({ + restaurant: one(restaurants, { + fields: [restaurantWorkshops.restaurantId], + references: [restaurants.id], + }), + workers: many(workshopWorkers), + dishesToWorkshops: many(dishesToWorkshops), + }), +); + +export type IRestaurantWorkshop = typeof restaurantWorkshops.$inferSelect; + +export const workshopWorkers = pgTable("workshop_workers", { + workerId: uuid("worker_id") + .notNull() + .references(() => workers.id), + workshopId: uuid("workshop_id") + .notNull() + .references(() => restaurantWorkshops.id), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), +}); + +export const workshopWorkerRelations = relations( + workshopWorkers, + ({ one }) => ({ + worker: one(workers, { + fields: [workshopWorkers.workerId], + references: [workers.id], + }), + workshop: one(restaurantWorkshops, { + fields: [workshopWorkers.workshopId], + references: [restaurantWorkshops.id], + }), + }), +); + +export type IWorkshopWorker = typeof workshopWorkers.$inferSelect; diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts new file mode 100644 index 0000000..1042ffa --- /dev/null +++ b/src/@base/drizzle/schema/restaurants.ts @@ -0,0 +1,133 @@ +import { dishModifiers } from "@postgress-db/schema/dish-modifiers"; +import { dishesToRestaurants } from "@postgress-db/schema/dishes"; +import { dishesMenusToRestaurants } from "@postgress-db/schema/dishes-menus"; +import { orders } from "@postgress-db/schema/orders"; +import { paymentMethods } from "@postgress-db/schema/payment-methods"; +import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; +import { workshiftPaymentCategories } from "@postgress-db/schema/workshift-payment-category"; +import { workshifts } from "@postgress-db/schema/workshifts"; +import { relations } from "drizzle-orm"; +import { + boolean, + index, + numeric, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { currencyEnum, dayOfWeekEnum } from "./general"; +import { workers, workersToRestaurants } from "./workers"; + +export const restaurants = pgTable( + "restaurants", + { + // Primary key + id: uuid("id").defaultRandom().primaryKey(), + + // Name of the restaurant // + name: text("name").notNull(), + + // Legal entity of the restaurant (can be a company or a person) // + legalEntity: text("legal_entity").notNull(), + + // Address of the restaurant // + address: text("address").notNull(), + latitude: numeric("latitude").notNull(), + longitude: numeric("longitude").notNull(), + + // Timezone of the restaurant // + timezone: text("timezone").notNull().default("Europe/Tallinn"), + + // Currency of the restaurant // + currency: currencyEnum("currency").notNull().default("EUR"), + + // Country code of the restaurant (used for mobile phone default and etc.) // + countryCode: text("country_code").notNull().default("EE"), + + // Is the restaurant enabled? // + isEnabled: boolean("is_enabled").notNull().default(false), + + // Is closed forever? // + isClosedForever: boolean("is_closed_forever").notNull().default(false), + + // Owner of the restaurant // + ownerId: uuid("owner_id"), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [index("idx_restaurants_owner_id").on(t.ownerId)], +); + +export const restaurantHours = pgTable("restaurant_hours", { + // Primary key // + id: uuid("id").defaultRandom().primaryKey(), + + // Restaurant // + restaurantId: uuid("restaurant_id").notNull(), + + // Day of the week // + dayOfWeek: dayOfWeekEnum("day_of_week").notNull(), + + // Opening and closing hours // + openingTime: text("opening_time").notNull(), + closingTime: text("closing_time").notNull(), + + // Is enabled? // + isEnabled: boolean("is_enabled").notNull().default(true), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +export const restaurantRelations = relations(restaurants, ({ one, many }) => ({ + restaurantHours: many(restaurantHours), + workersToRestaurants: many(workersToRestaurants), + workshops: many(restaurantWorkshops), + orders: many(orders), + dishesToRestaurants: many(dishesToRestaurants), + paymentMethods: many(paymentMethods), + owner: one(workers, { + fields: [restaurants.ownerId], + references: [workers.id], + }), + dishModifiers: many(dishModifiers), + dishesMenusToRestaurants: many(dishesMenusToRestaurants), + workshifts: many(workshifts), + workshiftPaymentCategories: many(workshiftPaymentCategories), +})); + +export const restaurantHourRelations = relations( + restaurantHours, + ({ one }) => ({ + restaurant: one(restaurants, { + fields: [restaurantHours.restaurantId], + references: [restaurants.id], + }), + }), +); + +export type IRestaurant = typeof restaurants.$inferSelect; +export type IRestaurantHours = typeof restaurantHours.$inferSelect; diff --git a/src/@base/drizzle/schema/sessions.ts b/src/@base/drizzle/schema/sessions.ts new file mode 100644 index 0000000..beb6550 --- /dev/null +++ b/src/@base/drizzle/schema/sessions.ts @@ -0,0 +1,57 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { workers } from "./workers"; + +export const sessions = pgTable( + "sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + previousId: uuid("previous_id"), + workerId: uuid("worker_id").notNull(), + httpAgent: text("http_agent"), + ip: text("ip"), + isActive: boolean("is_active").notNull().default(true), + onlineAt: timestamp("online_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + refreshedAt: timestamp("refreshed_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + index("idx_sessions_worker_id").on(t.workerId), + index("idx_sessions_previous_id").on(t.previousId), + ], +); + +export const sessionRelations = relations(sessions, ({ one }) => ({ + worker: one(workers, { + fields: [sessions.workerId], + references: [workers.id], + }), +})); + +export type ISession = typeof sessions.$inferSelect; diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts new file mode 100644 index 0000000..17c84a2 --- /dev/null +++ b/src/@base/drizzle/schema/workers.ts @@ -0,0 +1,142 @@ +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; +import { orderDeliveries } from "@postgress-db/schema/order-deliveries"; +import { orderDishesReturnments } from "@postgress-db/schema/order-dishes"; +import { orderHistoryRecords } from "@postgress-db/schema/order-history"; +import { orderPrechecks } from "@postgress-db/schema/order-prechecks"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { workshiftPayments } from "@postgress-db/schema/workshift-payments"; +import { + workersToWorkshifts, + workshifts, +} from "@postgress-db/schema/workshifts"; +import { relations } from "drizzle-orm"; +import { + boolean, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { z } from "zod"; + +import { workshopWorkers } from "./restaurant-workshop"; +import { sessions } from "./sessions"; + +export const workerRoleEnum = pgEnum("worker_role_enum", [ + "SYSTEM_ADMIN" as const, + "CHIEF_ADMIN", + "OWNER", + "ADMIN", + "KITCHENER", + "WAITER", + "CASHIER", + "DISPATCHER", + "COURIER", +]); + +export const ZodWorkerRole = z.enum(workerRoleEnum.enumValues); + +export type IRole = (typeof workerRoleEnum.enumValues)[number]; +export enum IRoleEnum { + SYSTEM_ADMIN = "SYSTEM_ADMIN", + CHIEF_ADMIN = "CHIEF_ADMIN", + OWNER = "OWNER", + ADMIN = "ADMIN", + KITCHENER = "KITCHENER", + WAITER = "WAITER", + CASHIER = "CASHIER", +} + +export const workers = pgTable("workers", { + id: uuid("id").defaultRandom().primaryKey(), + name: text("name").notNull().default("N/A"), + login: text("login").unique().notNull(), + role: workerRoleEnum("role").notNull(), + passwordHash: text("password_hash").notNull(), + isBlocked: boolean("is_blocked").notNull().default(false), + hiredAt: timestamp("hired_at", { withTimezone: true }), + firedAt: timestamp("fired_at", { withTimezone: true }), + onlineAt: timestamp("online_at", { withTimezone: true }), + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +export const workersToRestaurants = pgTable( + "workers_to_restaurants", + { + workerId: uuid("worker_id").notNull(), + restaurantId: uuid("restaurant_id").notNull(), + }, + (t) => [ + primaryKey({ + columns: [t.workerId, t.restaurantId], + }), + ], +); + +export type IWorkersToRestaurants = typeof workersToRestaurants.$inferSelect; + +export const workersToRestaurantsRelations = relations( + workersToRestaurants, + ({ one }) => ({ + worker: one(workers, { + fields: [workersToRestaurants.workerId], + references: [workers.id], + }), + restaurant: one(restaurants, { + fields: [workersToRestaurants.restaurantId], + references: [restaurants.id], + }), + }), +); + +export const workerRelations = relations(workers, ({ many }) => ({ + workersToRestaurants: many(workersToRestaurants), + sessions: many(sessions), + workshopWorkers: many(workshopWorkers), + deliveries: many(orderDeliveries), + ownedRestaurants: many(restaurants), + ownedDishesMenus: many(dishesMenus), + workshiftsOpened: many(workshifts, { + relationName: "workshiftsOpened", + }), + workshiftsClosed: many(workshifts, { + relationName: "workshiftsClosed", + }), + workersToWorkshifts: many(workersToWorkshifts), + workshiftPayments: many(workshiftPayments, { + relationName: "workshiftPaymentsWorker", + }), + removedWorkshiftPayments: many(workshiftPayments, { + relationName: "workshiftPaymentsRemovedByWorker", + }), + orderDishesReturnments: many(orderDishesReturnments), + orderPrechecks: many(orderPrechecks), + orderHistoryRecords: many(orderHistoryRecords), +})); + +export type IWorker = typeof workers.$inferSelect; +export type WorkerRole = typeof ZodWorkerRole._type; + +export const workerRoleRank: Record = { + KITCHENER: 0, + OWNER: 0, + WAITER: 0, + CASHIER: 0, + DISPATCHER: 0, + COURIER: 0, + ADMIN: 1, + CHIEF_ADMIN: 2, + SYSTEM_ADMIN: 3, +}; diff --git a/src/@base/drizzle/schema/workshift-enums.ts b/src/@base/drizzle/schema/workshift-enums.ts new file mode 100644 index 0000000..242e788 --- /dev/null +++ b/src/@base/drizzle/schema/workshift-enums.ts @@ -0,0 +1,18 @@ +import { pgEnum } from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const workshiftPaymentTypeEnum = pgEnum("workshift_payment_type", [ + "INCOME", + "EXPENSE", + "CASHLESS", +]); + +export const ZodWorkshiftPaymentType = z.enum( + workshiftPaymentTypeEnum.enumValues, +); + +export enum WorkshiftPaymentType { + INCOME = "INCOME", + EXPENSE = "EXPENSE", + CASHLESS = "CASHLESS", +} diff --git a/src/@base/drizzle/schema/workshift-payment-category.ts b/src/@base/drizzle/schema/workshift-payment-category.ts new file mode 100644 index 0000000..306007d --- /dev/null +++ b/src/@base/drizzle/schema/workshift-payment-category.ts @@ -0,0 +1,65 @@ +import { restaurants } from "@postgress-db/schema/restaurants"; +import { workshiftPayments } from "@postgress-db/schema/workshift-payments"; +import { relations } from "drizzle-orm"; +import { + boolean, + pgTable, + serial, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { workshiftPaymentTypeEnum } from "./workshift-enums"; + +export const workshiftPaymentCategories = pgTable( + "workshift_payment_categories", + { + id: uuid("id").defaultRandom().primaryKey(), + parentId: uuid("parent_id"), + restaurantId: uuid("restaurant_id").notNull(), + type: workshiftPaymentTypeEnum("type").notNull(), + + // Category info // + name: text("name").notNull(), + description: text("description"), + sortIndex: serial("sort_index").notNull(), + + // Flags // + isActive: boolean("is_active").notNull().default(true), + isRemoved: boolean("is_removed").notNull().default(false), + + // timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + removedAt: timestamp("removed_at", { withTimezone: true }), + }, +); + +export const workshiftPaymentCategoryRelations = relations( + workshiftPaymentCategories, + ({ one, many }) => ({ + parent: one(workshiftPaymentCategories, { + relationName: "parentToChild", + fields: [workshiftPaymentCategories.parentId], + references: [workshiftPaymentCategories.id], + }), + childrens: many(workshiftPaymentCategories, { + relationName: "parentToChild", + }), + restaurant: one(restaurants, { + fields: [workshiftPaymentCategories.restaurantId], + references: [restaurants.id], + }), + workshiftPayments: many(workshiftPayments), + }), +); diff --git a/src/@base/drizzle/schema/workshift-payments.ts b/src/@base/drizzle/schema/workshift-payments.ts new file mode 100644 index 0000000..103ba2f --- /dev/null +++ b/src/@base/drizzle/schema/workshift-payments.ts @@ -0,0 +1,76 @@ +import { currencyEnum } from "@postgress-db/schema/general"; +import { workers } from "@postgress-db/schema/workers"; +import { workshiftPaymentCategories } from "@postgress-db/schema/workshift-payment-category"; +import { workshifts } from "@postgress-db/schema/workshifts"; +import { relations } from "drizzle-orm"; +import { + boolean, + decimal, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { workshiftPaymentTypeEnum } from "./workshift-enums"; + +export const workshiftPayments = pgTable("workshift_payments", { + id: uuid("id").defaultRandom().primaryKey(), + categoryId: uuid("category_id").notNull(), + type: workshiftPaymentTypeEnum("type").notNull(), + + // Fields // + note: text("note"), + amount: decimal("amount", { precision: 10, scale: 2 }).notNull(), + currency: currencyEnum("currency").notNull(), + + // Workshift ID // + workshiftId: uuid("workshift_id").notNull(), + + // Worker ID (if exists) // + workerId: uuid("worker_id"), + removedByWorkerId: uuid("removed_by_worker_id"), + + // Boolean fields // + isRemoved: boolean("is_removed").notNull().default(false), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + removedAt: timestamp("removed_at", { withTimezone: true }), +}); + +export type IWorkshiftPayment = typeof workshiftPayments.$inferSelect; + +export const workshiftPaymentRelations = relations( + workshiftPayments, + ({ one }) => ({ + category: one(workshiftPaymentCategories, { + fields: [workshiftPayments.categoryId], + references: [workshiftPaymentCategories.id], + }), + workshift: one(workshifts, { + fields: [workshiftPayments.workshiftId], + references: [workshifts.id], + }), + worker: one(workers, { + fields: [workshiftPayments.workerId], + references: [workers.id], + relationName: "workshiftPaymentsWorker", + }), + removedByWorker: one(workers, { + fields: [workshiftPayments.removedByWorkerId], + references: [workers.id], + relationName: "workshiftPaymentsRemovedByWorker", + }), + }), +); diff --git a/src/@base/drizzle/schema/workshifts.ts b/src/@base/drizzle/schema/workshifts.ts new file mode 100644 index 0000000..55980d1 --- /dev/null +++ b/src/@base/drizzle/schema/workshifts.ts @@ -0,0 +1,105 @@ +import { restaurants } from "@postgress-db/schema/restaurants"; +import { workers } from "@postgress-db/schema/workers"; +import { workshiftPayments } from "@postgress-db/schema/workshift-payments"; +import { relations } from "drizzle-orm"; +import { + pgEnum, + pgTable, + primaryKey, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const workshiftStatusEnum = pgEnum("workshift_status_enum", [ + "PLANNED" as const, + "OPENED" as const, + "CLOSED" as const, +]); + +export const ZodWorkshiftStatus = z.enum(workshiftStatusEnum.enumValues); + +export enum IWorkshiftStatus { + PLANNED = "PLANNED", + OPENED = "OPENED", + CLOSED = "CLOSED", +} + +export const workshifts = pgTable("workshifts", { + id: uuid("id").defaultRandom().primaryKey(), + status: workshiftStatusEnum("status") + .notNull() + .default(IWorkshiftStatus.PLANNED), + + // Restaurant for which the workshift is // + restaurantId: uuid("restaurant_id").notNull(), + + // User IDs // + openedByWorkerId: uuid("opened_by_worker_id"), + closedByWorkerId: uuid("closed_by_worker_id"), + + // Timestamps // + createdAt: timestamp("created_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + openedAt: timestamp("opened_at", { withTimezone: true }), + closedAt: timestamp("closed_at", { withTimezone: true }), +}); + +export type IWorkshift = typeof workshifts.$inferSelect; + +export const workshiftsRelations = relations(workshifts, ({ one, many }) => ({ + restaurant: one(restaurants, { + fields: [workshifts.restaurantId], + references: [restaurants.id], + }), + openedByWorker: one(workers, { + fields: [workshifts.openedByWorkerId], + references: [workers.id], + relationName: "workshiftsOpened", + }), + closedByWorker: one(workers, { + fields: [workshifts.closedByWorkerId], + references: [workers.id], + relationName: "workshiftsClosed", + }), + workersToWorkshifts: many(workersToWorkshifts), + payments: many(workshiftPayments), +})); + +export const workersToWorkshifts = pgTable( + "workers_to_workshifts", + { + workerId: uuid("worker_id").notNull(), + workshiftId: uuid("workshift_id").notNull(), + }, + (t) => [ + primaryKey({ + columns: [t.workerId, t.workshiftId], + }), + ], +); + +export type IWorkersToWorkshifts = typeof workersToWorkshifts.$inferSelect; + +export const workersToWorkshiftsRelations = relations( + workersToWorkshifts, + ({ one }) => ({ + worker: one(workers, { + fields: [workersToWorkshifts.workerId], + references: [workers.id], + }), + workshift: one(workshifts, { + fields: [workersToWorkshifts.workshiftId], + references: [workshifts.id], + }), + }), +); diff --git a/src/@base/encryption/encryption.module.ts b/src/@base/encryption/encryption.module.ts new file mode 100644 index 0000000..ad2af7f --- /dev/null +++ b/src/@base/encryption/encryption.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { EncryptionService } from "./encryption.service"; + +@Module({ + providers: [EncryptionService], + exports: [EncryptionService], +}) +export class EncryptionModule {} diff --git a/src/@base/encryption/encryption.service.ts b/src/@base/encryption/encryption.service.ts new file mode 100644 index 0000000..be71bd3 --- /dev/null +++ b/src/@base/encryption/encryption.service.ts @@ -0,0 +1,140 @@ +import * as crypto from "crypto"; + +import { Injectable, Logger } from "@nestjs/common"; +import * as argon2 from "argon2"; + +@Injectable() +export class EncryptionService { + private readonly logger = new Logger(EncryptionService.name); + + private readonly algorithm = "aes-256-gcm"; + private readonly keyLength = 32; // 256 bits + private readonly ivLength = 12; // 96 bits recommended for GCM + private readonly authTagLength = 16; // 128 bits + private readonly saltLength = 32; // 128 bits for key derivation + + private readonly currentParams: argon2.Options = { + version: 1, + memoryCost: 65536, // 64MB in KiB + timeCost: 10, + parallelism: 4, + }; + + // Remove key storage from constructor since we'll derive it per operation + constructor() {} + + private async deriveKey( + salt: Buffer, + params: argon2.Options, + ): Promise { + // Use Argon2id which provides the best balance of security against both + // side-channel and GPU-based attacks + const derivedKey = await argon2.hash(process.env.ENCRYPTION_SECRET ?? "", { + salt, + type: argon2.argon2id, + memoryCost: params.memoryCost, + timeCost: params.timeCost, + parallelism: params.parallelism, + hashLength: this.keyLength, + raw: true, // Get raw buffer instead of encoded hash + }); + + return Buffer.from(derivedKey); + } + + private encodeParams(params: argon2.Options): Buffer { + const buffer = Buffer.alloc(13); // 4 + 4 + 4 + 1 bytes + buffer.writeUInt8(params.version ?? this.currentParams.version ?? 1, 0); + buffer.writeUInt32LE( + params.memoryCost ?? this.currentParams.memoryCost ?? 65536, + 1, + ); + buffer.writeUInt32LE( + params.timeCost ?? this.currentParams.timeCost ?? 10, + 5, + ); + buffer.writeUInt32LE( + params.parallelism ?? this.currentParams.parallelism ?? 4, + 9, + ); + return buffer; + } + + private decodeParams(buffer: Buffer): argon2.Options { + return { + version: buffer.readUInt8(0), + memoryCost: buffer.readUInt32LE(1), + timeCost: buffer.readUInt32LE(5), + parallelism: buffer.readUInt32LE(9), + }; + } + + async encrypt(text: string): Promise { + // Generate a random salt for key derivation + const salt = crypto.randomBytes(this.saltLength); + const params = this.currentParams; + const key = await this.deriveKey(salt, params); + + // Generate a random initialization vector + const iv = crypto.randomBytes(this.ivLength); + + // Create cipher + const cipher = crypto.createCipheriv(this.algorithm, key, iv); + + // Encrypt the text + const encrypted = Buffer.concat([ + cipher.update(text, "utf8"), + cipher.final(), + ]); + + // Get auth tag + const authTag = cipher.getAuthTag(); + + // Combine params, salt, IV, encrypted text, and auth tag + const paramsBuffer = this.encodeParams(params); + const result = Buffer.concat([paramsBuffer, salt, iv, encrypted, authTag]); + + // Return as base64 string + return result.toString("base64"); + } + + async decrypt(encryptedText: string): Promise { + try { + // Convert base64 string to buffer + const buffer = Buffer.from(encryptedText, "base64"); + + // Extract parameters, salt, IV, encrypted text, and auth tag + const paramsBuffer = buffer.subarray(0, 13); + const params = this.decodeParams(paramsBuffer); + + const salt = buffer.subarray(13, 13 + this.saltLength); + const iv = buffer.subarray( + 13 + this.saltLength, + 13 + this.saltLength + this.ivLength, + ); + const authTag = buffer.subarray(buffer.length - this.authTagLength); + const encrypted = buffer.subarray( + 13 + this.saltLength + this.ivLength, + buffer.length - this.authTagLength, + ); + + // Derive the key using the extracted salt and original parameters + const key = await this.deriveKey(salt, params); + + // Create decipher + const decipher = crypto.createDecipheriv(this.algorithm, key, iv); + decipher.setAuthTag(authTag); + + // Decrypt the text + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return decrypted.toString("utf8"); + } catch (error) { + this.logger.error("Decryption failed:", error); + throw new Error("Decryption failed. Data may be corrupted or tampered."); + } + } +} diff --git a/src/@base/redis/channels.ts b/src/@base/redis/channels.ts new file mode 100644 index 0000000..5cbb034 --- /dev/null +++ b/src/@base/redis/channels.ts @@ -0,0 +1,7 @@ +export enum RedisChannels { + COMMON = 1, + BULLMQ = 2, + SOCKET = 3, + REDLOCK = 4, + CACHE = 5, +} diff --git a/src/@base/redis/redis.utils.ts b/src/@base/redis/redis.utils.ts new file mode 100644 index 0000000..bc22a56 --- /dev/null +++ b/src/@base/redis/redis.utils.ts @@ -0,0 +1,20 @@ +import env from "@core/env"; + +export class RedisUtils { + public static buildKey(key: string | string[] | Record) { + const appName = "toite-api-instance"; + const version = "1.0.0"; + + const keyParts = [appName, version, env.NODE_ENV]; + + if (typeof key === "string") { + keyParts.push(key); + } else if (Array.isArray(key)) { + keyParts.push(...key); + } else if (typeof key === "object") { + keyParts.push(JSON.stringify(key)); + } + + return keyParts.join(":"); + } +} diff --git a/src/@base/redlock/redlock.module.ts b/src/@base/redlock/redlock.module.ts new file mode 100644 index 0000000..d4576ae --- /dev/null +++ b/src/@base/redlock/redlock.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { RedlockService } from "src/@base/redlock/redlock.service"; + +@Module({ + imports: [], + controllers: [], + providers: [RedlockService], + exports: [RedlockService], +}) +export class RedlockModule {} diff --git a/src/@base/redlock/redlock.service.ts b/src/@base/redlock/redlock.service.ts new file mode 100644 index 0000000..68a4056 --- /dev/null +++ b/src/@base/redlock/redlock.service.ts @@ -0,0 +1,40 @@ +import env from "@core/env"; +import { Injectable } from "@nestjs/common"; +import { Lock, Redlock, Settings } from "@sesamecare-oss/redlock"; +import { Redis } from "ioredis"; +import { RedisChannels } from "src/@base/redis/channels"; + +@Injectable() +export class RedlockService { + private readonly redlock: Redlock; + + constructor() { + const client = new Redis(`${env.REDIS_URL}/${RedisChannels.REDLOCK}`, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + retryStrategy(times) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + }); + + this.redlock = new Redlock([client], { + driftFactor: 0.01, + retryCount: 10, + retryDelay: 200, + retryJitter: 200, + }); + } + + public async acquire( + resources: string[], + duration: number, + settings?: Partial, + ) { + return await this.redlock.acquire(resources, duration, settings); + } + + public async release(lock: Lock, settings?: Partial) { + return await this.redlock.release(lock, settings); + } +} diff --git a/src/@base/s3/s3.module.ts b/src/@base/s3/s3.module.ts new file mode 100644 index 0000000..173ce51 --- /dev/null +++ b/src/@base/s3/s3.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { S3Service } from "src/@base/s3/s3.service"; + +@Module({ + imports: [], + providers: [S3Service], + exports: [S3Service], +}) +export class S3Module {} diff --git a/src/@base/s3/s3.service.ts b/src/@base/s3/s3.service.ts new file mode 100644 index 0000000..cf532bc --- /dev/null +++ b/src/@base/s3/s3.service.ts @@ -0,0 +1,110 @@ +import * as path from "path"; + +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import env from "@core/env"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { Injectable, Logger } from "@nestjs/common"; +import { MemoryStoredFile } from "nestjs-form-data"; +import { v4 as uuidv4 } from "uuid"; + +@Injectable() +export class S3Service { + private readonly logger = new Logger(S3Service.name); + private readonly client: S3Client; + readonly region: string; + readonly bucketName: string; + readonly endpoint: string; + + private _getEnvs() { + return { + region: env.S3_CONFIG.REGION, + accessKeyId: env.S3_CONFIG.ACCESS_KEY_ID, + secretAccessKey: env.S3_CONFIG.SECRET_ACCESS_KEY, + endpoint: env.S3_CONFIG.ENDPOINT, + bucketName: env.S3_CONFIG.BUCKET_NAME, + }; + } + + constructor() { + const { region, accessKeyId, secretAccessKey, endpoint, bucketName } = + this._getEnvs(); + + this.client = new S3Client({ + region, + credentials: { + accessKeyId, + secretAccessKey, + }, + endpoint, + forcePathStyle: true, + }); + + this.bucketName = bucketName; + this.region = region; + this.endpoint = endpoint; + } + + async uploadFile(file: MemoryStoredFile) { + const extension = path.extname(String(file.originalName)).toLowerCase(); + const id = uuidv4(); + const key = `${id}${extension}`; + + const command = new PutObjectCommand({ + Bucket: this.bucketName, + Key: key, + Body: file.buffer, + ContentType: file.mimeType, + }); + + try { + await this.client.send(command); + + return { + id, + extension, + key, + }; + } catch (error) { + this.logger.error(error); + + try { + // Delete file from S3 if upload failed // + await this.client.send( + new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: key, + }), + ); + } catch (e) {} + + throw new ServerErrorException(); + } + } + + async deleteFile(id: string, bucketName?: string) { + // Find file first + const findCommand = new GetObjectCommand({ + Bucket: bucketName ?? this.bucketName, + Key: id, + }); + + const response = await this.client.send(findCommand); + + if (!response.Body) { + throw new NotFoundException(); + } + + const command = new DeleteObjectCommand({ + Bucket: bucketName ?? this.bucketName, + Key: id, + }); + + await this.client.send(command); + } +} diff --git a/src/@base/snapshots/schemas/snapshot.schema.ts b/src/@base/snapshots/schemas/snapshot.schema.ts new file mode 100644 index 0000000..813ced2 --- /dev/null +++ b/src/@base/snapshots/schemas/snapshot.schema.ts @@ -0,0 +1,65 @@ +import { CrudAction } from "@core/types/general"; +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { IWorker } from "@postgress-db/schema/workers"; + +import { SnapshotChange, SnapshotModel } from "../types"; + +export type SnapshotDocument = Snapshot & Document; + +@Schema({ timestamps: true }) +export class Snapshot { + /** + * The id of the document that was changed + */ + @Prop({ required: true }) + documentId: string; + + /** + * The model that was changed + */ + @Prop({ + type: String, + required: true, + enum: SnapshotModel, + }) + model: SnapshotModel; + + /** + * The action that was taken + */ + @Prop({ + type: String, + required: true, + enum: CrudAction, + }) + action: CrudAction; + + /** + * The data that was changed + */ + @Prop({ required: false, type: Object }) + data: object | null; + + /** + * The id of the worker that made the change + */ + @Prop({ required: false, type: String }) + workerId: string | null; + + /** + * The worker that made the change + */ + @Prop({ required: false, type: Object }) + worker: Omit | null; + + /** + * Array of changes that were made in this snapshot + */ + @Prop({ required: false, type: Array }) + changes: SnapshotChange[]; + + createdAt: Date; + updatedAt: Date; +} + +export const SnapshotSchema = SchemaFactory.createForClass(Snapshot); diff --git a/src/@base/snapshots/snapshots.module.ts b/src/@base/snapshots/snapshots.module.ts new file mode 100644 index 0000000..4f63ad0 --- /dev/null +++ b/src/@base/snapshots/snapshots.module.ts @@ -0,0 +1,26 @@ +import { BullModule } from "@nestjs/bullmq"; +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { SnapshotsProcessor } from "src/@base/snapshots/snapshots.processor"; +import { SnapshotsProducer } from "src/@base/snapshots/snapshots.producer"; +import { SNAPSHOTS_QUEUE } from "src/@base/snapshots/types"; + +import { Snapshot, SnapshotSchema } from "./schemas/snapshot.schema"; +import { SnapshotsService } from "./snapshots.service"; + +@Module({ + imports: [ + DrizzleModule, + MongooseModule.forFeature([ + { name: Snapshot.name, schema: SnapshotSchema }, + ]), + BullModule.registerQueue({ + name: SNAPSHOTS_QUEUE, + }), + ], + controllers: [], + providers: [SnapshotsService, SnapshotsProcessor, SnapshotsProducer], + exports: [SnapshotsProducer], +}) +export class SnapshotsModule {} diff --git a/src/@base/snapshots/snapshots.processor.ts b/src/@base/snapshots/snapshots.processor.ts new file mode 100644 index 0000000..b3c8975 --- /dev/null +++ b/src/@base/snapshots/snapshots.processor.ts @@ -0,0 +1,75 @@ +import { CrudAction } from "@core/types/general"; +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Job } from "bullmq"; +import { Model } from "mongoose"; +import { + Snapshot, + SnapshotDocument, +} from "src/@base/snapshots/schemas/snapshot.schema"; +import { SnapshotsService } from "src/@base/snapshots/snapshots.service"; +import { + CreateSnapshotPayload, + SnapshotQueueJobName, + SNAPSHOTS_QUEUE, +} from "src/@base/snapshots/types"; + +@Processor(SNAPSHOTS_QUEUE, {}) +export class SnapshotsProcessor extends WorkerHost { + private readonly logger = new Logger(SnapshotsProcessor.name); + + constructor( + private readonly service: SnapshotsService, + @InjectModel(Snapshot.name) + private readonly snapshotModel: Model, + ) { + super(); + } + + async process(job: Job) { + const { name, data } = job; + + try { + switch (name) { + case SnapshotQueueJobName.CREATE_SNAPSHOT: { + await this.createSnapshot(data as CreateSnapshotPayload); + break; + } + + default: { + throw new Error(`Unknown job name: ${name}`); + } + } + } catch (error) { + this.logger.error(`Failed to process ${name} job`, error); + + throw error; + } + } + + private async createSnapshot(payload: CreateSnapshotPayload) { + const { model, data, documentId, workerId } = payload; + + const worker = await this.service.getWorker(workerId); + const previous = await this.service.getPreviousSnapshot(documentId, model); + const action = this.service.determinateAction(payload, previous); + + const changes = + action === CrudAction.UPDATE + ? this.service.calculateChanges(previous?.data ?? null, data) + : []; + + const snapshot = await this.snapshotModel.create({ + model, + action, + documentId, + data, + changes, + workerId: workerId ?? null, + worker, + }); + + await snapshot.save(); + } +} diff --git a/src/@base/snapshots/snapshots.producer.ts b/src/@base/snapshots/snapshots.producer.ts new file mode 100644 index 0000000..9518826 --- /dev/null +++ b/src/@base/snapshots/snapshots.producer.ts @@ -0,0 +1,38 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable, Logger } from "@nestjs/common"; +import { JobsOptions, Queue } from "bullmq"; +import { + CreateSnapshotPayload, + SnapshotQueueJobName, + SNAPSHOTS_QUEUE, +} from "src/@base/snapshots/types"; + +@Injectable() +export class SnapshotsProducer { + private readonly logger = new Logger(SnapshotsProducer.name); + + constructor( + @InjectQueue(SNAPSHOTS_QUEUE) + private readonly queue: Queue, + ) {} + + private async addJob( + name: SnapshotQueueJobName, + data: any, + opts?: JobsOptions, + ) { + try { + return await this.queue.add(name, data, opts); + } catch (error) { + this.logger.error(`Failed to add ${name} job to queue:`, error); + throw error; + } + } + + /** + * Creates a task to create a snapshot + */ + public async create(payload: CreateSnapshotPayload) { + return this.addJob(SnapshotQueueJobName.CREATE_SNAPSHOT, payload); + } +} diff --git a/src/@base/snapshots/snapshots.service.ts b/src/@base/snapshots/snapshots.service.ts new file mode 100644 index 0000000..5e72c5c --- /dev/null +++ b/src/@base/snapshots/snapshots.service.ts @@ -0,0 +1,101 @@ +import { CrudAction } from "@core/types/general"; +import { deepCompare } from "@core/utils/deep-compare"; +import { Inject, Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { schema } from "@postgress-db/drizzle.module"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Model } from "mongoose"; +import { + CreateSnapshotPayload, + SnapshotChange, +} from "src/@base/snapshots/types"; +import { PG_CONNECTION } from "src/constants"; + +import { Snapshot, SnapshotDocument } from "./schemas/snapshot.schema"; + +@Injectable() +export class SnapshotsService { + constructor( + @InjectModel(Snapshot.name) + private readonly snapshotModel: Model, + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + /** + * Determines the action to be taken based on the payload and previous snapshot + */ + determinateAction( + payload: CreateSnapshotPayload, + previousSnapshot: SnapshotDocument | null, + ) { + const { data } = payload; + + if (payload.action) return payload.action; + if (data === null) return CrudAction.DELETE; + if (!previousSnapshot) return CrudAction.CREATE; + + return CrudAction.UPDATE; + } + + /** + * Gets the previous snapshot for the document + */ + async getPreviousSnapshot(documentId: string, model: string) { + return await this.snapshotModel + .findOne({ documentId, model }) + .sort({ createdAt: -1 }) + .exec(); + } + + /** + * Calculates changes between two snapshots + */ + calculateChanges(oldData: any, newData: any): SnapshotChange[] { + const { changedPaths } = deepCompare(oldData, newData); + + return changedPaths.map((path) => ({ + path, + oldValue: path + .split(".") + .reduce((obj, key) => obj?.[key.replace(/\[\d+\]/, "")], oldData), + newValue: path + .split(".") + .reduce((obj, key) => obj?.[key.replace(/\[\d+\]/, "")], newData), + })); + } + + /** + * Gets worker by id + * @param workerId ID of the worker + * @returns Worker or null if worker is not found + */ + async getWorker(workerId?: string | null) { + if (!workerId) return null; + + const worker = await this.pg.query.workers.findFirst({ + where: eq(schema.workers.id, workerId), + with: { + workersToRestaurants: { + columns: { + restaurantId: true, + }, + }, + }, + columns: { + id: true, + name: true, + login: true, + role: true, + createdAt: true, + updatedAt: true, + firedAt: true, + hiredAt: true, + isBlocked: true, + onlineAt: true, + }, + }); + + return worker ?? null; + } +} diff --git a/src/@base/snapshots/types/index.ts b/src/@base/snapshots/types/index.ts new file mode 100644 index 0000000..fd642ba --- /dev/null +++ b/src/@base/snapshots/types/index.ts @@ -0,0 +1,27 @@ +import { CrudAction } from "@core/types/general"; + +export const SNAPSHOTS_QUEUE = "snapshots"; + +export enum SnapshotQueueJobName { + CREATE_SNAPSHOT = "create-snapshot", +} + +export enum SnapshotModel { + RESTAURANTS = "RESTAURANTS", + ORDERS = "ORDERS", + ORDER_DISHES = "ORDER_DISHES", +} + +export type CreateSnapshotPayload = { + documentId: string; + model: `${SnapshotModel}`; + action?: `${CrudAction}`; + data: any; + workerId?: string | null; +}; + +export interface SnapshotChange { + path: string; + oldValue: any; + newValue: any; +} diff --git a/src/@core/config/app.ts b/src/@core/config/app.ts index 2bab655..8f1bdf4 100644 --- a/src/@core/config/app.ts +++ b/src/@core/config/app.ts @@ -1,17 +1,39 @@ -import { INestApplication } from "@nestjs/common"; -import * as cookieParser from "cookie-parser"; +import env from "@core/env"; +import { HttpExceptionFilter } from "@core/errors/http-exception-filter"; +import fastifyCookie from "@fastify/cookie"; +import multipart from "@fastify/multipart"; +import { NestFastifyApplication } from "@nestjs/platform-fastify"; +import { I18nValidationPipe } from "nestjs-i18n"; -export const configApp = (app: INestApplication) => { +export const configApp = async (app: NestFastifyApplication) => { // Parse cookies - app.use(cookieParser()); + await app.register(fastifyCookie, { + secret: env.COOKIES_SECRET, + }); + + await app.register(multipart, { + limits: { + fileSize: 1024 * 1024 * 8, // 8MB + }, + }); + + app.useGlobalPipes( + new I18nValidationPipe({ + transform: true, + }), + ); + + // app.useGlobalFilters(new I18nValidationExceptionFilter()); + app.useGlobalFilters(new HttpExceptionFilter()); // Enable CORS app.enableCors({ origin: [ - "http://localhost:3000", - "http://localhost:3035", - "http://127.0.0.1:3000", - "http://127.0.0.1:3035", + "*", + // "http://localhost:3000", + // "http://localhost:3035", + // "http://127.0.0.1:3000", + // "http://127.0.0.1:3035", ], methods: "GET,HEAD,PUT,PATCH,POST,DELETE", preflightContinue: false, diff --git a/src/@core/decorators/controller.decorator.ts b/src/@core/decorators/controller.decorator.ts index 111ff7b..d89a09e 100644 --- a/src/@core/decorators/controller.decorator.ts +++ b/src/@core/decorators/controller.decorator.ts @@ -41,6 +41,8 @@ export function Controller( BaseController({ path, }), + + // UsePipes(new Validator()), UsePipes(Validator), UseGuards(ThrottlerGuard), ApiTags(...(!tags ? [path] : tags)), diff --git a/src/@core/decorators/cookies.decorator.ts b/src/@core/decorators/cookies.decorator.ts index b74f1e1..72f7c4b 100644 --- a/src/@core/decorators/cookies.decorator.ts +++ b/src/@core/decorators/cookies.decorator.ts @@ -1,3 +1,4 @@ +import { Request } from "@core/interfaces/request"; import { createParamDecorator, ExecutionContext } from "@nestjs/common"; /** @@ -6,7 +7,8 @@ import { createParamDecorator, ExecutionContext } from "@nestjs/common"; */ export const Cookies = createParamDecorator( (data: string, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); + const request = ctx.switchToHttp().getRequest(); + return data ? request.cookies?.[data] : request.cookies; }, ); diff --git a/src/@core/decorators/cursor.decorator.ts b/src/@core/decorators/cursor.decorator.ts new file mode 100644 index 0000000..183d738 --- /dev/null +++ b/src/@core/decorators/cursor.decorator.ts @@ -0,0 +1,72 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { Request } from "@core/interfaces/request"; +import { addMetadata } from "@core/utils/addMetadata"; +import { createParamDecorator } from "@nestjs/common"; +import { ExecutionContextHost } from "@nestjs/core/helpers/execution-context-host"; + +export interface ICursorParams { + default?: { + limit?: number; + }; +} + +export interface ICursor { + cursorId: string | null; + limit: number; +} + +export const CURSOR_DEFAULT_LIMIT = 50; + +type QueryParams = { + cursorId?: string; + limit?: string; +}; + +export const CursorParams = createParamDecorator( + (options: ICursorParams, ctx: ExecutionContextHost): ICursor => { + const req = ctx.switchToHttp().getRequest() as Request; + + const cursorId = (req.query as QueryParams)?.cursorId ?? null; + const limit = + (req.query as QueryParams)?.limit ?? + options?.default?.limit ?? + CURSOR_DEFAULT_LIMIT; + + if (!!cursorId && typeof cursorId !== "string") { + throw new BadRequestException("errors.common.invalid-cursor-id"); + } + + if (isNaN(Number(limit)) || Number(limit) < 1) { + throw new BadRequestException("errors.common.invalid-limit-value"); + } + + if (!!limit && Number(limit) > 1000) { + throw new BadRequestException("errors.common.limit-too-big"); + } + + return { + cursorId, + limit: Number(limit), + }; + }, + [ + addMetadata([ + { + in: "query", + name: "cursorId", + type: "string", + description: "Cursor id", + required: false, + example: "123", + }, + { + in: "query", + name: "limit", + type: "number", + description: "Limit", + required: false, + example: 50, + }, + ]), + ], +); diff --git a/src/@core/decorators/filter.decorator.ts b/src/@core/decorators/filter.decorator.ts new file mode 100644 index 0000000..277de79 --- /dev/null +++ b/src/@core/decorators/filter.decorator.ts @@ -0,0 +1,99 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { Request } from "@core/interfaces/request"; +import { addMetadata } from "@core/utils/addMetadata"; +import { createParamDecorator } from "@nestjs/common"; +import { ExecutionContextHost } from "@nestjs/core/helpers/execution-context-host"; + +export enum FilterCondition { + Equals = "equals", + NotEquals = "notEquals", + Contains = "contains", + NotContains = "notContains", + StartsWith = "startsWith", + EndsWith = "endsWith", +} + +export interface IFilter { + field: string; + value: string; + condition: FilterCondition; +} + +export interface IFilters { + filters: IFilter[]; +} + +type QueryParams = { + filters?: string; +}; + +export const FilterParams = createParamDecorator( + (options: any, ctx: ExecutionContextHost): IFilters => { + const req = ctx.switchToHttp().getRequest() as Request; + const rawFilters = (req.query as QueryParams)?.filters; + + if (!rawFilters) { + return { filters: [] }; + } + + try { + const filters = JSON.parse(rawFilters as string); + + if (!Array.isArray(filters)) { + throw new BadRequestException("errors.common.invalid-filters-format", { + property: "filters", + }); + // throw new BadRequestException({ + // title: "Invalid filters format", + // description: "Filters should be an array", + // }); + } + + // Validate each filter + filters.forEach((filter) => { + if (!filter.field || !filter.value || !filter.condition) { + throw new BadRequestException("errors.common.invalid-filter-format", { + property: "filters", + }); + // throw new BadRequestException({ + // title: "Invalid filter format", + // description: "Each filter must have field, value and condition", + // }); + } + + if (!Object.values(FilterCondition).includes(filter.condition)) { + throw new BadRequestException( + "errors.common.invalid-filter-condition", + ); + // throw new BadRequestException({ + // title: "Invalid filter condition", + // description: `Condition must be one of: ${Object.values( + // FilterCondition, + // ).join(", ")}`, + // }); + } + }); + + return { filters }; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new BadRequestException("errors.common.invalid-filters-format", { + property: "filters", + }); + } + }, + [ + addMetadata([ + { + in: "query", + name: "filters", + type: "string", + description: "JSON string containing filters array", + required: false, + example: '[{"field":"name","value":"John","condition":"contains"}]', + }, + ]), + ], +); diff --git a/src/@core/decorators/ip-address.decorator.ts b/src/@core/decorators/ip-address.decorator.ts new file mode 100644 index 0000000..1a66e05 --- /dev/null +++ b/src/@core/decorators/ip-address.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import * as requestIp from "@supercharge/request-ip"; +import { Request } from "express"; + +const IpAddress = createParamDecorator( + (data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + return String(requestIp.getClientIp(request)); + }, +); + +export default IpAddress; diff --git a/src/@core/decorators/is-phone.decorator.ts b/src/@core/decorators/is-phone.decorator.ts new file mode 100644 index 0000000..f1e41e2 --- /dev/null +++ b/src/@core/decorators/is-phone.decorator.ts @@ -0,0 +1,36 @@ +import { registerDecorator, ValidationOptions } from "@i18n-class-validator"; +import { isValidPhoneNumber } from "libphonenumber-js"; +import { I18nContext } from "nestjs-i18n"; + +export function IsPhoneNumber( + validationOptions?: ValidationOptions & { + isOptional?: boolean; + }, +) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: "isPhoneNumber", + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + if ( + validationOptions?.isOptional && + (value === undefined || value === null || value === "") + ) { + return true; + } + + return typeof value === "string" && isValidPhoneNumber(value); + }, + defaultMessage() { + const i18n = I18nContext.current(); + const errorText = i18n?.t("validation.validators.isPhoneNumber"); + + return `${errorText}`; + }, + }, + }); + }; +} diff --git a/src/@core/decorators/is-time-format.decorator.ts b/src/@core/decorators/is-time-format.decorator.ts new file mode 100644 index 0000000..dfd0f3b --- /dev/null +++ b/src/@core/decorators/is-time-format.decorator.ts @@ -0,0 +1,28 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from "@i18n-class-validator"; + +export function IsTimeFormat(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: "isTimeFormat", + target: object.constructor, + propertyName: propertyName, + options: { + message: "Time must be in HH:MM format (24-hour)", + ...validationOptions, + }, + validator: { + validate(value: any) { + if (typeof value !== "string") return false; + return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be in HH:MM format (24-hour)`; + }, + }, + }); + }; +} diff --git a/src/@core/decorators/pagination.decorator.ts b/src/@core/decorators/pagination.decorator.ts index 57fd09a..5bd64b4 100644 --- a/src/@core/decorators/pagination.decorator.ts +++ b/src/@core/decorators/pagination.decorator.ts @@ -21,32 +21,31 @@ export interface IPagination { offset: number; } +type QueryParams = { + page?: string; + size?: string; +}; + export const PaginationParams = createParamDecorator( (options: PaginationParams, ctx: ExecutionContextHost): IPagination => { - const { - default: { - page: defaultPage = PAGINATION_DEFAULT_PAGE, - limit: defaultLimit = PAGINATION_DEFAULT_LIMIT, - }, - } = options || { default: {} }; + const defaultPage = options?.default?.page ?? PAGINATION_DEFAULT_PAGE; + const defaultLimit = options?.default?.limit ?? PAGINATION_DEFAULT_LIMIT; const req = ctx.switchToHttp().getRequest() as Request; - const page = Number(req.query?.page || defaultPage); - const size = Number(req.query?.size || defaultLimit); + const page = Number((req.query as QueryParams)?.page || defaultPage); + const size = Number((req.query as QueryParams)?.size || defaultLimit); if (isNaN(page) || page < 1 || isNaN(size) || size < 1) { - throw new BadRequestException({ - title: "Invalid pagination params", - description: "Page and size should be positive integers", + throw new BadRequestException("errors.common.invalid-pagination-params", { + property: "page", }); } // do not allow to fetch large slices of the dataset if (size > 100) { - throw new BadRequestException({ - title: "Invalid pagination size", - description: "Max size is 100", + throw new BadRequestException("errors.common.invalid-pagination-size", { + property: "size", }); } diff --git a/src/@core/decorators/roles.decorator.ts b/src/@core/decorators/roles.decorator.ts index becc2d7..b987260 100644 --- a/src/@core/decorators/roles.decorator.ts +++ b/src/@core/decorators/roles.decorator.ts @@ -1,5 +1,5 @@ import { SetMetadata } from "@nestjs/common"; -import { WorkerRole } from "@postgress-db/schema"; +import { WorkerRole } from "@postgress-db/schema/workers"; export const ROLES_KEY = "roles"; export const Roles = (...roles: WorkerRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/@core/decorators/search.decorator.ts b/src/@core/decorators/search.decorator.ts new file mode 100644 index 0000000..a71ba2d --- /dev/null +++ b/src/@core/decorators/search.decorator.ts @@ -0,0 +1,40 @@ +import { Request } from "@core/interfaces/request"; +import { StringValuePipe } from "@core/pipes/string.pipe"; +import { addMetadata } from "@core/utils/addMetadata"; +import { createParamDecorator } from "@nestjs/common"; +import { ExecutionContextHost } from "@nestjs/core/helpers/execution-context-host"; + +type QueryParams = { + search?: string; +}; + +// @deprecated cause of ValidationPipe +const SearchQuery = createParamDecorator( + (options: any, ctx: ExecutionContextHost): string | null => { + const req = ctx.switchToHttp().getRequest() as Request; + const search = (req.query as QueryParams)?.search ?? null; + + const pipe = new StringValuePipe(); + + const transformed = pipe.transform(String(search), { + type: "query", + data: "search", + }); + + return transformed; + }, + [ + addMetadata([ + { + in: "query", + name: "search", + type: "string", + description: "Search query string", + required: false, + example: "pizza", + }, + ]), + ], +); + +export default SearchQuery; diff --git a/src/@core/decorators/sorting.decorator.ts b/src/@core/decorators/sorting.decorator.ts index 9a2f934..2280677 100644 --- a/src/@core/decorators/sorting.decorator.ts +++ b/src/@core/decorators/sorting.decorator.ts @@ -1,6 +1,7 @@ import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; import { Request } from "@core/interfaces/request"; import { addMetadata } from "@core/utils/addMetadata"; +import camelToSnakeCase from "@core/utils/camel-to-snake"; import { createParamDecorator } from "@nestjs/common"; import { ExecutionContextHost } from "@nestjs/core/helpers/execution-context-host"; @@ -18,12 +19,17 @@ export type SortingParamsOptions = { fields: string[]; }; +type QueryParams = { + sortBy?: string; + sortOrder?: string; +}; + export const SortingParams = createParamDecorator( (options: SortingParamsOptions, ctx: ExecutionContextHost): ISorting => { const { fields } = options; const req = ctx.switchToHttp().getRequest() as Request; - const sortBy = req.query?.sortBy; - const sortOrder = req.query?.sortOrder; + const sortBy = (req.query as QueryParams)?.sortBy; + const sortOrder = (req.query as QueryParams)?.sortOrder; if (!sortBy && !sortOrder) { return { @@ -33,40 +39,32 @@ export const SortingParams = createParamDecorator( } if (typeof sortBy !== "string") { - throw new BadRequestException({ - title: "Invalid sortBy", - description: `sortBy should be a string, but got ${typeof sortBy}`, + throw new BadRequestException("errors.common.invalid-sort-by-field", { + property: "sortBy", }); } if (typeof sortOrder !== "string") { - throw new BadRequestException({ - title: "Invalid sortOrder", - description: - "sortOrder should be a string, but got " + typeof sortOrder, + throw new BadRequestException("errors.common.invalid-sort-order-field", { + property: "sortOrder", }); } if (!fields.includes(sortBy)) { throw new BadRequestException( // "Allowed fields for sortBy: " + fields.join(", "), - { - title: "Invalid sortBy field", - description: "Allowed fields for sortBy: " + fields.join(", "), - details: fields, - }, + "errors.common.invalid-sort-by-field", ); } if (sortOrder !== SortOrder.ASC && sortOrder !== SortOrder.DESC) { - throw new BadRequestException({ - title: "Invalid sortOrder value", - description: `sortOrder should be either "asc" or "desc", but got "${sortOrder}"`, + throw new BadRequestException("errors.common.invalid-sort-order-field", { + property: "sortOrder", }); } return { - sortBy, + sortBy: camelToSnakeCase(sortBy), sortOrder, }; }, diff --git a/src/@core/decorators/user-agent.decorator.ts b/src/@core/decorators/user-agent.decorator.ts new file mode 100644 index 0000000..0422e79 --- /dev/null +++ b/src/@core/decorators/user-agent.decorator.ts @@ -0,0 +1,21 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { Request } from "express"; + +/** + * Custom decorator to extract the User-Agent header from the request. + * + * @param data - Optional data to customize the decorator. Not used in this case. + * @param ctx - The execution context which contains the HTTP request. + * @returns The User-Agent string from the request headers. + */ +const UserAgent = createParamDecorator( + (data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + + return ( + request.headers["user-agent"] || (request.headers["User-Agent"] as string) + ); + }, +); + +export default UserAgent; diff --git a/src/@core/dto/cursor-meta.dto.ts b/src/@core/dto/cursor-meta.dto.ts new file mode 100644 index 0000000..48b7635 --- /dev/null +++ b/src/@core/dto/cursor-meta.dto.ts @@ -0,0 +1,37 @@ +import { ICursor } from "@core/decorators/cursor.decorator"; +import { IsNotEmpty, IsNumber, IsString } from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export interface ICursorMeta extends Pick { + total: number; +} + +export class CursorMetaDto implements ICursorMeta { + @Expose() + @IsString() + @IsNotEmpty() + @ApiPropertyOptional({ + description: "Cursor id", + example: "123", + }) + cursorId: string | null; + + @Expose() + @IsNumber() + @IsNotEmpty() + @ApiProperty({ + description: "Limit", + example: 20, + }) + limit: number; + + @Expose() + @IsNumber() + @IsNotEmpty() + @ApiProperty({ + description: "Total number of items", + example: 100, + }) + total: number; +} diff --git a/src/@core/dto/cursor-pagination-meta.dto.ts b/src/@core/dto/cursor-pagination-meta.dto.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/@core/dto/cursor-response.entity.ts b/src/@core/dto/cursor-response.entity.ts new file mode 100644 index 0000000..7c511b8 --- /dev/null +++ b/src/@core/dto/cursor-response.entity.ts @@ -0,0 +1,16 @@ +import { IsArray } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; + +import { CursorMetaDto } from "./cursor-meta.dto"; + +export class CursorResponseDto { + @IsArray() + @Expose() + data: unknown[]; + + @Expose() + @ApiProperty({ description: "Meta information about pagination" }) + @Type(() => CursorMetaDto) + meta: CursorMetaDto; +} diff --git a/src/@core/dto/pagination-meta.dto.ts b/src/@core/dto/pagination-meta.dto.ts index 2be5ad4..0932bd2 100644 --- a/src/@core/dto/pagination-meta.dto.ts +++ b/src/@core/dto/pagination-meta.dto.ts @@ -1,7 +1,7 @@ import { IPagination } from "@core/decorators/pagination.decorator"; +import { IsNumber } from "@i18n-class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsNumber } from "class-validator"; export interface IPaginationMeta extends Pick { diff --git a/src/@core/dto/pagination-response.entity.ts b/src/@core/dto/pagination-response.entity.ts index 1fb29bf..89fb87f 100644 --- a/src/@core/dto/pagination-response.entity.ts +++ b/src/@core/dto/pagination-response.entity.ts @@ -1,6 +1,6 @@ +import { IsArray } from "@i18n-class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { Expose, Type } from "class-transformer"; -import { IsArray } from "class-validator"; import { PaginationMetaDto } from "./pagination-meta.dto"; diff --git a/src/@core/env.ts b/src/@core/env.ts new file mode 100644 index 0000000..9783959 --- /dev/null +++ b/src/@core/env.ts @@ -0,0 +1,93 @@ +import "dotenv/config"; +import { z } from "zod"; + +const validInitPass = (pass: string) => { + const isDev = process.env?.NODE_ENV === "development"; + + if (isDev && pass.length >= 6) return true; + + if (pass.length < 8) return false; + if (!/[A-Z]/.test(pass)) return false; + if (!/[a-z]/.test(pass)) return false; + if (!/[0-9]/.test(pass)) return false; + if (!/[!@#$%^&*]/.test(pass)) return false; + + return true; +}; + +const validPostgresUrl = (url: string) => url.includes("postgres"); +const validMongoUrl = (url: string) => + url.includes("mongodb") && url.includes("authSource"); + +const validRedisUrl = (url: string) => url.includes("redis"); + +export const envSchema = z.object({ + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + PORT: z.coerce.number().default(6701), + + CSRF_SECRET: z.string(), + COOKIES_SECRET: z.string(), + // JWT // + JWT: z.object({ + SECRET: z.string(), + GRACE_PERIOD: z.coerce.number().default(60), // Default 1 minute + REFRESH_INTERVAL: z.coerce.number().default(60 * 15), // Default 15 minutes + EXPIRES_IN: z.coerce.number().default(60 * 60 * 24 * 31), // Default 31 days + }), + // --- // + // Databases // + POSTGRESQL_URL: z.string().refine(validPostgresUrl), + MONGO_URL: z.string().refine(validMongoUrl, { + message: "Make sure that link is valid and have authSource", + }), + REDIS_URL: z.string().refine(validRedisUrl), + // -------- // + + INITIAL_ADMIN_PASSWORD: z.string().refine(validInitPass, { + message: "Password not secure enough", + }), + + DADATA_API_TOKEN: z.string(), + GOOGLE_MAPS_API_KEY: z.string(), + + S3_CONFIG: z.object({ + ACCESS_KEY_ID: z.string(), + SECRET_ACCESS_KEY: z.string(), + BUCKET_NAME: z.string(), + ENDPOINT: z.string().url(), + REGION: z.string(), + }), + + DEV_SECRET_KEY: z.string().optional().nullable(), +}); + +const env = envSchema.parse({ + NODE_ENV: process.env.NODE_ENV, + PORT: process.env.PORT, + CSRF_SECRET: process.env.CSRF_SECRET, + COOKIES_SECRET: process.env.COOKIES_SECRET, + JWT: { + SECRET: process.env.JWT_SECRET, + GRACE_PERIOD: process.env.JWT_GRACE_PERIOD, + REFRESH_INTERVAL: process.env.JWT_REFRESH_INTERVAL, + EXPIRES_IN: process.env.JWT_EXPIRES_IN, + }, + POSTGRESQL_URL: process.env.POSTGRESQL_URL, + MONGO_URL: process.env.MONGO_URL, + REDIS_URL: process.env.REDIS_URL, + INITIAL_ADMIN_PASSWORD: process.env.INITIAL_ADMIN_PASSWORD, + DADATA_API_TOKEN: process.env.DADATA_API_TOKEN, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + S3_CONFIG: { + ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID, + SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY, + BUCKET_NAME: process.env.S3_BUCKET_NAME, + ENDPOINT: process.env.S3_ENDPOINT, + REGION: process.env.S3_REGION, + }, + DEV_SECRET_KEY: process.env?.DEV_SECRET_KEY ?? null, +}); + +export default env; diff --git a/src/@core/errors/exceptions/bad-request.exception.ts b/src/@core/errors/exceptions/bad-request.exception.ts index e27797b..a60255d 100644 --- a/src/@core/errors/exceptions/bad-request.exception.ts +++ b/src/@core/errors/exceptions/bad-request.exception.ts @@ -1,16 +1,17 @@ import { BadRequestException as Exception } from "@nestjs/common"; -import { ErrorInstance, ErrorMessage } from "../index.types"; +import { ErrorInstance, ErrorOptions } from "../index.types"; /** * Bad request exception which is thrown if any parameters of request is not valid * @see [Exception filters - NestJS](https://docs.nestjs.com/exception-filters) */ export class BadRequestException extends Exception { - constructor(message?: ErrorMessage) { + constructor(message?: string, options?: ErrorOptions) { super({ errorCode: "BAD_REQUEST", - message: message || "Bad request", + message, + options, } as ErrorInstance); } } diff --git a/src/@core/errors/exceptions/conflict.exception.ts b/src/@core/errors/exceptions/conflict.exception.ts index 471c147..4845699 100644 --- a/src/@core/errors/exceptions/conflict.exception.ts +++ b/src/@core/errors/exceptions/conflict.exception.ts @@ -1,16 +1,17 @@ import { ConflictException as Exception } from "@nestjs/common"; -import { ErrorInstance, ErrorMessage } from "../index.types"; +import { ErrorInstance, ErrorOptions } from "../index.types"; /** * Conflict exception which is thrown if provided data conflict with actual entries in database * @see [Exception filters - NestJS](https://docs.nestjs.com/exception-filters) */ export class ConflictException extends Exception { - constructor(message?: ErrorMessage) { + constructor(message?: string, options?: ErrorOptions) { super({ errorCode: "CONFLICT", - message: message || "Conflict", + message, + options, } as ErrorInstance); } } diff --git a/src/@core/errors/exceptions/forbidden.exception.ts b/src/@core/errors/exceptions/forbidden.exception.ts index 673db2b..c89b39d 100644 --- a/src/@core/errors/exceptions/forbidden.exception.ts +++ b/src/@core/errors/exceptions/forbidden.exception.ts @@ -1,16 +1,17 @@ import { ForbiddenException as Exception } from "@nestjs/common"; -import { ErrorInstance, ErrorMessage } from "../index.types"; +import { ErrorInstance, ErrorOptions } from "../index.types"; /** * Forbidden exception which is thrown if user are not allow to access the data * @see [Exception filters - NestJS](https://docs.nestjs.com/exception-filters) */ export class ForbiddenException extends Exception { - constructor(message?: ErrorMessage) { + constructor(message?: string, options?: ErrorOptions) { super({ errorCode: "FORBIDDEN", - message: message || "Forbidden", + message, + options, } as ErrorInstance); } } diff --git a/src/@core/errors/exceptions/form.exception.ts b/src/@core/errors/exceptions/form.exception.ts index 0a3478b..6a2a344 100644 --- a/src/@core/errors/exceptions/form.exception.ts +++ b/src/@core/errors/exceptions/form.exception.ts @@ -1,6 +1,6 @@ import { BadRequestException as Exception } from "@nestjs/common"; -import { ErrorInstance, ErrorMessage } from "../index.types"; +import { ErrorInstance, ErrorOptions } from "../index.types"; export type FormExceptionDetail = { property: string; @@ -9,10 +9,11 @@ export type FormExceptionDetail = { }; export class FormException extends Exception { - constructor(message?: ErrorMessage) { + constructor(message?: string, options?: ErrorOptions) { super({ errorCode: "FORM", - message: message || "Some fields are not valid", - } as ErrorInstance); + message, + options, + } as ErrorInstance); } } diff --git a/src/@core/errors/exceptions/not-found.exception.ts b/src/@core/errors/exceptions/not-found.exception.ts index d2a408b..a4ea0db 100644 --- a/src/@core/errors/exceptions/not-found.exception.ts +++ b/src/@core/errors/exceptions/not-found.exception.ts @@ -1,16 +1,17 @@ import { NotFoundException as Exception } from "@nestjs/common"; -import { ErrorInstance, ErrorMessage } from "../index.types"; +import { ErrorInstance, ErrorOptions } from "../index.types"; /** * Not found exception which is thrown if called data doesn't exist in database * @see [Exception filters - NestJS](https://docs.nestjs.com/exception-filters) */ export class NotFoundException extends Exception { - constructor(message?: ErrorMessage) { + constructor(message?: string, options?: ErrorOptions) { super({ errorCode: "NOT_FOUND", - message: message || "Not found", + message, + options, } as ErrorInstance); } } diff --git a/src/@core/errors/exceptions/server-error.exception.ts b/src/@core/errors/exceptions/server-error.exception.ts index 171b0c1..e114409 100644 --- a/src/@core/errors/exceptions/server-error.exception.ts +++ b/src/@core/errors/exceptions/server-error.exception.ts @@ -1,6 +1,6 @@ import { InternalServerErrorException as Exception } from "@nestjs/common"; -import { ErrorInstance, ErrorMessage } from "../index.types"; +import { ErrorInstance, ErrorOptions } from "../index.types"; /** * Server error exception which is thrown on any internal error @@ -8,10 +8,11 @@ import { ErrorInstance, ErrorMessage } from "../index.types"; * @see [Exception filters - NestJS](https://docs.nestjs.com/exception-filters) */ export class ServerErrorException extends Exception { - constructor(message?: ErrorMessage) { + constructor(message?: string, options?: ErrorOptions) { super({ errorCode: "SERVER_ERROR", - message: message || "Server error", + message, + options, } as ErrorInstance); } } diff --git a/src/@core/errors/exceptions/unauthorized.exception.ts b/src/@core/errors/exceptions/unauthorized.exception.ts index b8a29ca..5cad64e 100644 --- a/src/@core/errors/exceptions/unauthorized.exception.ts +++ b/src/@core/errors/exceptions/unauthorized.exception.ts @@ -1,16 +1,17 @@ import { UnauthorizedException as Exception } from "@nestjs/common"; -import { ErrorInstance, ErrorMessage } from "../index.types"; +import { ErrorInstance, ErrorOptions } from "../index.types"; /** * Unauthorized exception which is thrown on wrong credentials in login request * @see [Exception filters - NestJS](https://docs.nestjs.com/exception-filters) */ export class UnauthorizedException extends Exception { - constructor(message?: ErrorMessage) { + constructor(message?: string, options?: ErrorOptions) { super({ errorCode: "UNAUTHORIZED", - message: message || "Unauthorized", + message, + options, } as ErrorInstance); } } diff --git a/src/@core/errors/filter.ts b/src/@core/errors/filter.ts deleted file mode 100644 index 3642c61..0000000 --- a/src/@core/errors/filter.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { slugify } from "@core/utils/slugify"; -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpStatus, -} from "@nestjs/common"; -import { HttpAdapterHost } from "@nestjs/core"; - -import { ErrorInstance } from "./index.types"; - -@Catch() -export class AllExceptionsFilter implements ExceptionFilter { - constructor(private readonly httpAdapterHost: HttpAdapterHost) {} - - catch(exception: unknown, host: ArgumentsHost): void { - const { httpAdapter } = this.httpAdapterHost; - - const ctx = host.switchToHttp(); - - const response = (exception as any)?.response as ErrorInstance; - const statusCode = (exception as { status: number })?.status; - const url = httpAdapter.getRequestUrl(ctx.getRequest()); - const uri = new URL("http://localhost" + url); - - const errorCategory = slugify(uri.pathname.split("/")?.[1]).toUpperCase(); - - httpAdapter.reply( - ctx.getResponse(), - { - statusCode, - errorCode: response.errorCode, - errorCategory, - errorSubCode: null, - ...(typeof response?.message === "object" && - "title" in response?.message - ? { - errorSubCode: slugify(response.message.title), - } - : {}), - ...response, - pathname: uri.pathname, - timestamp: Date.now(), - }, - statusCode || HttpStatus.INTERNAL_SERVER_ERROR, - ); - } -} diff --git a/src/@core/errors/handleError.ts b/src/@core/errors/handle-error.ts similarity index 100% rename from src/@core/errors/handleError.ts rename to src/@core/errors/handle-error.ts diff --git a/src/@core/errors/http-exception-filter.ts b/src/@core/errors/http-exception-filter.ts new file mode 100644 index 0000000..b5efa18 --- /dev/null +++ b/src/@core/errors/http-exception-filter.ts @@ -0,0 +1,90 @@ +// import { ValidationError } from "@i18n-class-validator"; +import { ErrorInstance } from "@core/errors/index.types"; +import { Request } from "@core/interfaces/request"; +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, +} from "@nestjs/common"; +import { FastifyReply } from "fastify"; +import { I18nContext, I18nValidationException } from "nestjs-i18n"; +import { formatI18nErrors } from "nestjs-i18n/dist/utils"; + +export interface IValidationError { + property: string; + constraints: Record; +} + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private getValidationErrors( + exception: HttpException, + ): IValidationError[] | null { + const i18n = I18nContext.current(); + + if (i18n && exception instanceof I18nValidationException) { + const errors = formatI18nErrors(exception.errors ?? [], i18n.service, { + lang: i18n.lang, + }); + + return errors.map((error) => ({ + property: error.property, + constraints: error?.constraints ?? {}, + })); + } + + return null; + } + + private getError(exception: HttpException) { + const i18n = I18nContext.current(); + const response = exception.getResponse() as ErrorInstance; + + if (!response ?? !response?.errorCode) return null; + + const tKey = response?.message ?? `errors.${response.errorCode}`; + const message = i18n?.t(tKey) ?? response?.message ?? null; + + return { + message, + validationError: + !!message && response.options?.property + ? ({ + property: response.options.property, + constraints: { + exception: String(message), + }, + } as IValidationError) + : null, + }; + } + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const reply = ctx.getResponse(); + const statusCode = exception.getStatus(); + + const timestamp = request.timestamp ?? new Date().getTime(); + const error = this.getError(exception); + + const validationErrors = [ + ...(this.getValidationErrors(exception) ?? []), + ...(error?.validationError ? [error.validationError] : []), + ].filter(Boolean); + + const message = error?.message ?? exception.message; + + return reply.status(statusCode).send({ + statusCode, + ...(request?.requestId ? { requestId: request.requestId } : {}), + path: request.url, + timestamp, + message, + ...(validationErrors && validationErrors.length > 0 + ? { validationErrors } + : {}), + }); + } +} diff --git a/src/@core/errors/index.types.ts b/src/@core/errors/index.types.ts index ecaf308..e76502b 100644 --- a/src/@core/errors/index.types.ts +++ b/src/@core/errors/index.types.ts @@ -1,12 +1,9 @@ -export type ErrorMessage = - | { - title: string; - description?: string; - details?: T; - } - | string; +export interface ErrorOptions { + property?: string; +} -export interface ErrorInstance { +export interface ErrorInstance { errorCode: string; - message: ErrorMessage; + message?: string; + options?: ErrorOptions; } diff --git a/src/@core/guards/roles.guard.ts b/src/@core/guards/roles.guard.ts deleted file mode 100644 index 0b77563..0000000 --- a/src/@core/guards/roles.guard.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ROLES_KEY } from "@core/decorators/roles.decorator"; -import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; -import { Request } from "@core/interfaces/request"; -import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; -import { WorkerRole } from "@postgress-db/schema"; - -@Injectable() -export class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - const roles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); - - const notAllowed = () => { - throw new ForbiddenException({ - title: "Forbidden", - description: "You don't have permission to access this resource", - }); - }; - - // If there is no roles, then allow access - if (!roles) return true; - - const request = context.switchToHttp().getRequest() as Request; - - if (!request?.worker?.role) return notAllowed(); - - const isAllowed = roles.includes(request.worker.role); - - if (isAllowed) return true; - - return notAllowed(); - } -} diff --git a/src/@core/interfaces/request.ts b/src/@core/interfaces/request.ts index 847f846..dfda80a 100644 --- a/src/@core/interfaces/request.ts +++ b/src/@core/interfaces/request.ts @@ -1,7 +1,39 @@ -import { ISession, IWorker } from "@postgress-db/schema"; -import { Request as Req } from "express"; +import { IRestaurant } from "@postgress-db/schema/restaurants"; +import { ISession } from "@postgress-db/schema/sessions"; +import { IWorker, IWorkersToRestaurants } from "@postgress-db/schema/workers"; +import { FastifyRequest } from "fastify"; -export interface Request extends Req { - worker?: Omit & { passwordHash: undefined }; - session?: ISession; +export type RequestWorker = Pick< + IWorker, + | "id" + | "name" + | "login" + | "role" + | "isBlocked" + | "hiredAt" + | "firedAt" + | "onlineAt" + | "createdAt" + | "updatedAt" +> & { + workersToRestaurants: Pick[]; + ownedRestaurants: Pick[]; +}; + +export type RequestSession = Pick< + ISession, + "id" | "previousId" | "workerId" | "isActive" | "refreshedAt" +> & { + worker: RequestWorker | null; +}; + +export interface Request extends FastifyRequest { + requestId?: string; + timestamp?: number; + worker?: RequestWorker | null; + session?: RequestSession | null; + user?: { + id: string; + [key: string]: any; + }; } diff --git a/src/@core/interfaces/response.ts b/src/@core/interfaces/response.ts index 0b192d0..2716088 100644 --- a/src/@core/interfaces/response.ts +++ b/src/@core/interfaces/response.ts @@ -1,6 +1,6 @@ -import { Response as Res } from "express"; +import { FastifyReply } from "fastify"; /** * Interface describes response object * @see [Response - Express](https://expressjs.com/ru/api.html#res) */ -export type Response = Res; +export type Response = FastifyReply; diff --git a/src/@core/pipes/no-omit-validation.pipe.ts b/src/@core/pipes/no-omit-validation.pipe.ts index aacb636..50a5d9e 100644 --- a/src/@core/pipes/no-omit-validation.pipe.ts +++ b/src/@core/pipes/no-omit-validation.pipe.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/ban-types */ import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; -import { handleError } from "@core/errors/handleError"; +import { handleError } from "@core/errors/handle-error"; +import { validate } from "@i18n-class-validator"; import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common"; import { plainToClass } from "class-transformer"; -import { validate } from "class-validator"; /** * Validates input data by class validator decorators diff --git a/src/@core/pipes/string-array.pipe.ts b/src/@core/pipes/string-array.pipe.ts new file mode 100644 index 0000000..10946c1 --- /dev/null +++ b/src/@core/pipes/string-array.pipe.ts @@ -0,0 +1,49 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ArgumentMetadata, PipeTransform } from "@nestjs/common"; + +export type StringArrayPipeOptions = { + allowedValues?: string[]; +}; + +export class StringArrayPipe implements PipeTransform { + constructor(private readonly options?: StringArrayPipeOptions) {} + + transform(value: string, metadata: ArgumentMetadata): string[] | null { + if (!value) return null; + + if ( + typeof value === "string" && + (value === "undefined" || value === "null") + ) { + return null; + } + + const values = value.split(","); + + if (values.length === 0) { + throw new BadRequestException("errors.string-array-pipe.empty-array", { + property: metadata.data, + }); + } + + if (this.options?.allowedValues) { + const lowerCasedValues = values.map((value) => value.toLowerCase()); + const lowerCasedAllowedValues = this.options.allowedValues.map((value) => + value.toLowerCase(), + ); + + for (const value of lowerCasedValues) { + if (!lowerCasedAllowedValues.includes(value)) { + throw new BadRequestException( + "errors.string-value-pipe.invalid-value", + { + property: metadata.data, + }, + ); + } + } + } + + return values; + } +} diff --git a/src/@core/pipes/string.pipe.ts b/src/@core/pipes/string.pipe.ts new file mode 100644 index 0000000..09681ee --- /dev/null +++ b/src/@core/pipes/string.pipe.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ArgumentMetadata, PipeTransform } from "@nestjs/common"; + +export type StringValuePipeOptions = { + allowedValues?: string[]; +}; + +export class StringValuePipe implements PipeTransform { + constructor(private readonly options?: StringValuePipeOptions) {} + + transform(value: string, metadata: ArgumentMetadata): string | null { + if (!value) return null; + + if ( + typeof value === "string" && + (value === "undefined" || value === "null") + ) { + return null; + } + + if (this.options?.allowedValues) { + const lowerCased = value.toLowerCase(); + const lowerCasedAllowedValues = this.options.allowedValues.map((value) => + value.toLowerCase(), + ); + + if (!lowerCasedAllowedValues.includes(lowerCased)) { + throw new BadRequestException( + "errors.string-value-pipe.invalid-value", + { + property: metadata.data, + }, + ); + } + } + + return value ?? null; + } +} diff --git a/src/@core/pipes/validation.pipe.ts b/src/@core/pipes/validation.pipe.ts index 13dd967..8b5daa6 100644 --- a/src/@core/pipes/validation.pipe.ts +++ b/src/@core/pipes/validation.pipe.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/ban-types */ -import { FormException } from "@core/errors/exceptions/form.exception"; -import { handleError } from "@core/errors/handleError"; +import { handleError } from "@core/errors/handle-error"; +import { validate } from "@i18n-class-validator"; import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common"; import { plainToClass } from "class-transformer"; -import { validate } from "class-validator"; import { BadRequestException } from "../errors/exceptions/bad-request.exception"; @@ -29,8 +28,10 @@ export class ValidationPipe implements PipeTransform { * * {@link core/errors/exceptions/bad-request.exception!BadRequestException} * if input data is not valid */ - public async transform(value: unknown, { metatype }: ArgumentMetadata) { + public async transform(value: unknown, { metatype, type }: ArgumentMetadata) { try { + if (type === "query") return value; + if (typeof value === "string" && metatype === Date) { return new Date(value); } @@ -38,10 +39,7 @@ export class ValidationPipe implements PipeTransform { if (metatype === String) return String(value); if (typeof value !== "object" && metatype === Object) { - throw new BadRequestException({ - title: "Body error", - description: "Body should be an object", - }); + throw new BadRequestException("errors.common.body-should-be-an-object"); } if (!metatype || !this.toValidate(metatype)) { @@ -58,20 +56,19 @@ export class ValidationPipe implements PipeTransform { }); if (errors.length > 0) { - const messages = errors.map(({ constraints }) => { - const [key] = Object.keys(constraints); - return `${constraints[key]}`; - }); - - throw new FormException({ - title: "Validation error", - description: messages.join(", "), - details: errors.map(({ property, constraints }) => ({ - property, - message: messages.join("; "), - constraints, - })), - }); + // const messages = errors.map(({ constraints }) => { + // const [key] = Object.keys(constraints ?? {}); + // return `${constraints?.[key]}`; + // }); + // throw new FormException({ + // title: "Validation error", + // description: messages.join(", "), + // details: errors.map(({ property, constraints }) => ({ + // property, + // message: messages.join("; "), + // constraints, + // })), + // }); } return object; diff --git a/src/@core/types/general.ts b/src/@core/types/general.ts new file mode 100644 index 0000000..bf64a6e --- /dev/null +++ b/src/@core/types/general.ts @@ -0,0 +1,17 @@ +export enum DayOfWeekEnum { + monday = "monday", + tuesday = "tuesday", + wednesday = "wednesday", + thursday = "thursday", + friday = "friday", + saturday = "saturday", + sunday = "sunday", +} + +export type DayOfWeek = keyof typeof DayOfWeekEnum; + +export enum CrudAction { + CREATE = "CREATE", + UPDATE = "UPDATE", + DELETE = "DELETE", +} diff --git a/src/@core/utils/camel-to-snake.ts b/src/@core/utils/camel-to-snake.ts new file mode 100644 index 0000000..33f6a49 --- /dev/null +++ b/src/@core/utils/camel-to-snake.ts @@ -0,0 +1,6 @@ +export default function camelToSnakeCase(str: string): string { + return str + .replace(/([A-Z])/g, "_$1") + .toLowerCase() + .replace(/^_/, ""); +} diff --git a/src/@core/utils/deep-compare.ts b/src/@core/utils/deep-compare.ts new file mode 100644 index 0000000..e7775bc --- /dev/null +++ b/src/@core/utils/deep-compare.ts @@ -0,0 +1,63 @@ +type CompareResult = { + isEqual: boolean; + changedPaths: string[]; +}; + +export function deepCompare( + obj1: any, + obj2: any, + path: string = "", +): CompareResult { + // Handle primitive types and null/undefined + if (obj1 === obj2) return { isEqual: true, changedPaths: [] }; + if (!obj1 || !obj2) return { isEqual: false, changedPaths: [path] }; + if (typeof obj1 !== typeof obj2) + return { isEqual: false, changedPaths: [path] }; + + // Handle Dates + if (obj1 instanceof Date && obj2 instanceof Date) { + return { + isEqual: obj1.getTime() === obj2.getTime(), + changedPaths: obj1.getTime() === obj2.getTime() ? [] : [path], + }; + } + + // Handle Arrays + if (Array.isArray(obj1) && Array.isArray(obj2)) { + if (obj1.length !== obj2.length) + return { isEqual: false, changedPaths: [path] }; + + const result: CompareResult = { isEqual: true, changedPaths: [] }; + + for (let i = 0; i < obj1.length; i++) { + const comparison = deepCompare(obj1[i], obj2[i], `${path}[${i}]`); + if (!comparison.isEqual) { + result.isEqual = false; + result.changedPaths.push(...comparison.changedPaths); + } + } + + return result; + } + + // Handle Objects + if (typeof obj1 === "object" && typeof obj2 === "object") { + const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]); + const result: CompareResult = { isEqual: true, changedPaths: [] }; + + for (const key of Array.from(allKeys)) { + const newPath = path ? `${path}.${key}` : key; + const comparison = deepCompare(obj1[key], obj2[key], newPath); + + if (!comparison.isEqual) { + result.isEqual = false; + result.changedPaths.push(...comparison.changedPaths); + } + } + + return result; + } + + // Handle other cases (primitives that aren't equal) + return { isEqual: false, changedPaths: [path] }; +} diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts new file mode 100644 index 0000000..0abd357 --- /dev/null +++ b/src/@socket/socket.gateway.ts @@ -0,0 +1,409 @@ +import env from "@core/env"; +import { Logger } from "@nestjs/common"; +import { + ConnectedSocket, + MessageBody, + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from "@nestjs/websockets"; +import Redis from "ioredis"; +import { Socket } from "socket.io"; +import { RedisChannels } from "src/@base/redis/channels"; +import { + GatewayClient, + GatewayClients, + GatewayIncomingMessage, + GatewayMessage, + GatewayWorker, +} from "src/@socket/socket.types"; +import { AuthService } from "src/auth/services/auth.service"; + +import { SocketUtils } from "./socket.utils"; + +@WebSocketGateway() +export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + private server: Socket; + + private readonly logger = new Logger(SocketGateway.name); + + // Gateway ID for synchronization between gateways + private readonly gatewayId: string; + + // Redis instances for synchronization between gateways + private publisherRedis: Redis; + private subscriberRedis: Redis; + + // Discovery interval + private discoveryInterval: NodeJS.Timeout; + private readonly DISCOVERY_INTERVAL = 1000; // milliseconds + private readonly REDIS_CLIENTS_TTL = 5; // seconds + + // Local state of the gateway + private localClients: GatewayClients = []; + private localClientsCurrentPathnameMap: Record = {}; + private localClientsSocketMap: Map = new Map(); + private localWorkersMap: Map = new Map(); + + constructor(private readonly authService: AuthService) { + this.gatewayId = SocketUtils.generateGatewayId(); + } + + /** + * Get a Redis client + * @returns Redis client + */ + private _getRedis() { + const client = new Redis(`${env.REDIS_URL}/${RedisChannels.SOCKET}`, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + retryStrategy(times) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + }); + + client.on("error", (error) => { + this.logger.error("Redis client error:", error); + }); + + return client; + } + + async onModuleInit() { + this.publisherRedis = this._getRedis(); + this.subscriberRedis = this._getRedis(); + + this.subscriberRedis.subscribe(`${this.gatewayId}-messages`); + this.subscriberRedis.on("message", (channel, message) => { + this._handleMessage(JSON.parse(message) as GatewayMessage[]); + }); + + await this._updateDiscovery(); + + this.discoveryInterval = setInterval(async () => { + await this._updateDiscovery(); + }, this.DISCOVERY_INTERVAL); + } + + /** + * Handle a message from Redis + * @param messages - The messages to handle + */ + private async _handleMessage(messages: GatewayMessage[]) { + try { + // Group messages by clientId for efficient socket emission + const messagesByClient = messages.reduce( + (acc, message) => { + if (!acc[message.clientId]) { + acc[message.clientId] = []; + } + acc[message.clientId].push(message); + return acc; + }, + {} as Record, + ); + + // Emit messages for each client + Object.entries(messagesByClient).forEach(([clientId, clientMessages]) => { + const socket = this.localClientsSocketMap.get(clientId); + if (socket) { + clientMessages.forEach((message) => { + socket.emit(message.event, message.data); + }); + } + }); + } catch (error) { + this.logger.error(error); + } + } + + /** + * Update the discovery status in Redis + */ + private async _updateDiscovery() { + try { + const pipeline = this.publisherRedis.pipeline(); + + pipeline.setex( + `${this.gatewayId}:clients`, + this.REDIS_CLIENTS_TTL, + JSON.stringify(this.localClients), + ); + + pipeline.setex( + `${this.gatewayId}:workers`, + this.REDIS_CLIENTS_TTL, + JSON.stringify(Object.fromEntries(this.localWorkersMap.entries())), + ); + + pipeline.setex( + `${this.gatewayId}:current-pathnames`, + this.REDIS_CLIENTS_TTL, + JSON.stringify(this.localClientsCurrentPathnameMap), + ); + + await pipeline.exec(); + } catch (error) { + this.logger.error(error); + } + } + + private async _getDesiredWorker(socket: Socket) { + if (env.NODE_ENV !== "development") { + return null; + } + + const desiredWorkerId = SocketUtils.getDesiredWorkerId(socket); + + if (!desiredWorkerId) { + return null; + } + + return await this.authService.getAuthWorker(desiredWorkerId); + } + + /** + * Get a worker for the socket connection + * @param socket - The socket connection + * @returns The worker + */ + private async _getWorker(socket: Socket) { + const desiredWorker = await this._getDesiredWorker(socket); + + if (desiredWorker) { + return desiredWorker; + } + + const signed = SocketUtils.getClientAuthCookie(socket); + const httpAgent = SocketUtils.getUserAgent(socket); + const clientIp = SocketUtils.getClientIp(socket); + + if (!signed) { + throw new Error("No signed cookie found"); + } + + const session = await this.authService.validateSession(signed, { + httpAgent, + ip: clientIp, + }); + + if (!session) { + throw new Error("Invalid session"); + } + + if (!session.worker) { + throw new Error("Invalid session"); + } + + if (session.worker.isBlocked) { + throw new Error("Worker is blocked"); + } + + return session.worker; + } + + /** + * Get all clients from all gateways + * @returns All clients + */ + public async getClients(): Promise { + const gatewayKeys = await this.publisherRedis.keys( + `${SocketUtils.commonGatewaysIdentifier}:*:clients`, + ); + + const clientsRaw = await this.publisherRedis.mget(gatewayKeys); + + const clients: GatewayClient[] = clientsRaw + .filter(Boolean) + .flatMap((client) => JSON.parse(String(client))); + + return clients; + } + + /** + * Get all workers from all gateways + * @returns All workers + */ + public async getWorkers(): Promise> { + const gatewayKeys = await this.publisherRedis.keys( + `${SocketUtils.commonGatewaysIdentifier}:*:workers`, + ); + + const workersRaw = await this.publisherRedis.mget(gatewayKeys); + + const workers: GatewayWorker[] = Object.values( + workersRaw + .filter(Boolean) + .flatMap((workers) => + Object.values( + JSON.parse(String(workers)) as Record, + ), + ), + ); + + return workers.reduce( + (acc, worker) => { + if ( + acc?.[worker.id] && + new Date(worker.connectedAt).getTime() < + new Date(acc[worker.id].connectedAt).getTime() + ) { + return acc; + } + + acc[worker.id] = worker; + + return acc; + }, + {} as Record, + ); + } + + /** + * Get all subscriptions from all gateways + * @returns All subscriptions + */ + public async getCurrentPathnames(): Promise> { + const gatewayKeys = await this.publisherRedis.keys( + `${SocketUtils.commonGatewaysIdentifier}:*:current-pathnames`, + ); + + const currentPathnamesRaw = await this.publisherRedis.mget(gatewayKeys); + + const currentPathnames = currentPathnamesRaw.reduce( + (acc, currentPathnameRaw) => { + if (!currentPathnameRaw) { + return acc; + } + + const currentPathname = JSON.parse( + String(currentPathnameRaw), + ) as Record; + + Object.entries(currentPathname).forEach(([clientId, pathname]) => { + acc[clientId] = pathname; + }); + + return acc; + }, + {} as Record, + ); + + return currentPathnames; + } + + public async emit( + messages: { recipient: GatewayClient; event: string; data: any }[], + ) { + // Group messages by gateway for batch publishing + const messagesByGateway = messages.reduce( + (acc, { recipient, event, data }) => { + if (!acc[recipient.gatewayId]) { + acc[recipient.gatewayId] = []; + } + acc[recipient.gatewayId].push({ + clientId: recipient.clientId, + event, + data, + }); + return acc; + }, + {} as Record, + ); + + // Handle local emissions + const localMessages = messagesByGateway[this.gatewayId] ?? []; + localMessages.forEach((message) => { + const localSocket = this.localClientsSocketMap.get(message.clientId); + if (localSocket) { + localSocket.emit(message.event, message.data); + } + }); + + // Create batched messages for each gateway + const pipeline = this.publisherRedis.pipeline(); + + Object.entries(messagesByGateway).forEach(([gatewayId, messages]) => { + if (gatewayId === this.gatewayId) return; + + pipeline.publish(`${gatewayId}-messages`, JSON.stringify(messages)); + }); + + try { + await pipeline.exec(); + } catch (error) { + this.logger.error("Error publishing messages:", error); + } + } + + /** + * Handle a new connection + * @param socket - The socket connection + */ + async handleConnection(socket: Socket) { + try { + const worker = await this._getWorker(socket); + const connectedAt = new Date(); + + this.localClients.push({ + clientId: socket.id, + workerId: worker.id, + gatewayId: this.gatewayId, + connectedAt, + } satisfies GatewayClient); + + this.localClientsSocketMap.set(socket.id, socket); + + this.localWorkersMap.set(worker.id, { + id: worker.id, + role: worker.role, + restaurants: worker.workersToRestaurants, + connectedAt, + } satisfies GatewayWorker); + } catch (error) { + this.logger.error(error); + socket.disconnect(true); + } + } + + /** + * Handle a disconnection + * @param socket - The socket connection + */ + async handleDisconnect(socket: Socket) { + try { + this.localClients = this.localClients.filter( + (client) => client.clientId !== socket.id, + ); + + this.localClientsSocketMap.delete(socket.id); + delete this.localClientsCurrentPathnameMap[socket.id]; + } catch (error) { + this.logger.error(error); + socket.disconnect(true); + } + } + + /** + * Handle a current pathname + * @param incomingData - The incoming data + * @param socket - The socket connection + */ + @SubscribeMessage(GatewayIncomingMessage.CURRENT_PATHNAME) + async handleCurrentPathname( + @MessageBody() incomingData: { pathname: string }, + @ConnectedSocket() socket: Socket, + ) { + const clientId = socket.id; + + try { + this.localClientsCurrentPathnameMap[clientId] = incomingData.pathname; + } catch (error) { + this.logger.error(error); + } + } +} diff --git a/src/@socket/socket.module.ts b/src/@socket/socket.module.ts new file mode 100644 index 0000000..7f35d79 --- /dev/null +++ b/src/@socket/socket.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { SocketGateway } from "src/@socket/socket.gateway"; +import { AuthModule } from "src/auth/auth.module"; + +import { SocketService } from "./socket.service"; + +@Module({ + imports: [AuthModule], + providers: [SocketService, SocketGateway], + exports: [SocketService], +}) +export class SocketModule {} diff --git a/src/@socket/socket.service.ts b/src/@socket/socket.service.ts new file mode 100644 index 0000000..2ec3fee --- /dev/null +++ b/src/@socket/socket.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from "@nestjs/common"; +import { SocketGateway } from "src/@socket/socket.gateway"; +import { GatewayClient, SocketEmitTo } from "src/@socket/socket.types"; + +@Injectable() +export class SocketService { + constructor(private readonly socketGateway: SocketGateway) {} + + public async getClients() { + return await this.socketGateway.getClients(); + } + + public async getWorkers() { + return await this.socketGateway.getWorkers(); + } + + public async getCurrentPathnames() { + return await this.socketGateway.getCurrentPathnames(); + } + + public async emit( + messages: { recipient: GatewayClient; event: string; data: any }[], + ) { + return await this.socketGateway.emit(messages); + } + + public async emitToMultiple( + recipients: GatewayClient[], + event: string, + data: any, + ) { + return await this.socketGateway.emit( + recipients.map((recipient) => ({ + recipient, + event, + data, + })), + ); + } + + public async emitTo(to: SocketEmitTo, event: string, data: any) { + const clients = await this.getClients(); + + const findClientIdsSet = new Set(to.clientIds); + const findWorkerIdsSet = new Set(to.workerIds); + + // Get array of recipients (clients) that will receive the message + const recipients = clients.filter((client) => { + if (to?.clientIds && findClientIdsSet.has(client.clientId)) return true; + if (to?.workerIds && findWorkerIdsSet.has(client.workerId)) return true; + + return false; + }); + + return await this.emitToMultiple(recipients, event, data); + } +} diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts new file mode 100644 index 0000000..9e6466a --- /dev/null +++ b/src/@socket/socket.types.ts @@ -0,0 +1,57 @@ +import { IWorker, IWorkersToRestaurants } from "@postgress-db/schema/workers"; + +export enum GatewayIncomingMessage { + CURRENT_PATHNAME = "CURRENT_PATHNAME", +} + +export interface GatewayClient { + currentPathname?: string; + clientId: string; + gatewayId: string; + workerId: string; + connectedAt: Date; +} + +export type GatewayClients = GatewayClient[]; + +export type GatewayWorker = Pick & { + restaurants: Pick[]; + connectedAt: Date; +}; + +export type GatewayMessage = { + clientId: string; + event: string; + data: any; +}; + +export type SocketEmitTo = + | { + clientIds: string[]; + workerIds: undefined; + } + | { + clientIds: undefined; + workerIds: string[]; + }; + +export enum SocketEventType { + /** For /orders/:orderId page */ + REVALIDATE_ORDER_PAGE = "R-3JK", + /** For /orders/dispatcher */ + REVALIDATE_DISPATCHER_ORDERS_PAGE = "R-ZGI", + /** For /orders/kitchen */ + REVALIDATE_KITCHENER_ORDERS_PAGE = "R-a2I", +} + +export type SocketRevalidateOrderEvent = { + orderId: string; +}; + +export type SocketNewOrderEvent = { + orderId: string; +}; + +export interface SocketEvent { + type: `${SocketEventType}`; +} diff --git a/src/@socket/socket.utils.ts b/src/@socket/socket.utils.ts new file mode 100644 index 0000000..d90018e --- /dev/null +++ b/src/@socket/socket.utils.ts @@ -0,0 +1,60 @@ +import env from "@core/env"; +import { parse as parseCookie } from "cookie"; +import { Socket } from "socket.io"; +import { AUTH_COOKIES } from "src/auth/auth.types"; +import { v4 as uuidv4 } from "uuid"; + +export class SocketUtils { + public static get commonGatewaysIdentifier() { + return `socket-gateway(${env.NODE_ENV})`; + } + + public static generateGatewayId() { + const gatewayId = uuidv4() + .replaceAll("-", "") + .replaceAll(" ", "") + .replaceAll("_", "") + .replaceAll(":", "") + .replaceAll(".", ""); + + return `${SocketUtils.commonGatewaysIdentifier}:${gatewayId}`; + } + + public static getClientAuthCookie(socket: Socket): string | null { + const cookies = parseCookie(socket.handshake.headers.cookie || ""); + const auth = cookies?.[AUTH_COOKIES.token]; + + return auth ?? null; + } + + public static getUserAgent(socket: Socket): string { + const headers = socket.handshake.headers; + + return (headers["user-agent"] || headers["User-Agent"]) as string; + } + + public static getClientIp(socket: Socket): string { + return ( + socket.handshake.address ?? + socket.handshake.headers["x-forwarded-for"] ?? + socket.conn.remoteAddress + ); + } + + public static getDesiredWorkerId(socket: Socket) { + const headers = socket.handshake.headers; + + const secretKey = headers["x-dev-secret-key"]; + const desiredWorkerId = headers["x-dev-worker-id"]; + + if (secretKey !== env.DEV_SECRET_KEY) { + return null; + } + + if (!desiredWorkerId || typeof desiredWorkerId !== "string") { + return null; + } + + return desiredWorkerId; + } +} diff --git a/src/addresses/addresses.controller.ts b/src/addresses/addresses.controller.ts new file mode 100644 index 0000000..bc07b76 --- /dev/null +++ b/src/addresses/addresses.controller.ts @@ -0,0 +1,50 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Get, Query } from "@nestjs/common"; +import { + ApiForbiddenResponse, + ApiOperation, + ApiResponse, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; + +import { GetSuggestionsDto } from "./dto/get-suggestions.dto"; +import { AddressSuggestion } from "./entities/suggestion.entity"; +import { AddressesService } from "./services/addresses.service"; + +@Controller("addresses") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class AddressesController { + constructor(private readonly addressesService: AddressesService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get("suggestions") + @ApiOperation({ + summary: "Get address suggestions", + description: + "Returns address suggestions based on the search query. Supports multiple providers (Dadata and Google) and different languages.", + }) + @Serializable(AddressSuggestion) + @ApiResponse({ + status: 200, + description: "Returns array of address suggestions", + type: AddressSuggestion, + isArray: true, + }) + @ApiResponse({ + status: 400, + description: "Invalid input parameters", + }) + async getSuggestions( + @Query() { query, provider, language, includeRaw }: GetSuggestionsDto, + ): Promise { + return this.addressesService.getSuggestions( + query, + provider, + language, + includeRaw, + ); + } +} diff --git a/src/addresses/addresses.module.ts b/src/addresses/addresses.module.ts new file mode 100644 index 0000000..eff982b --- /dev/null +++ b/src/addresses/addresses.module.ts @@ -0,0 +1,15 @@ +import { HttpModule } from "@nestjs/axios"; +import { Module } from "@nestjs/common"; + +import { AddressesController } from "./addresses.controller"; +import { AddressesService } from "./services/addresses.service"; +import { DadataService } from "./services/dadata.service"; +import { GoogleService } from "./services/google.service"; + +@Module({ + imports: [HttpModule], + controllers: [AddressesController], + providers: [AddressesService, DadataService, GoogleService], + exports: [AddressesService], +}) +export class AddressesModule {} diff --git a/src/addresses/dto/get-suggestions.dto.ts b/src/addresses/dto/get-suggestions.dto.ts new file mode 100644 index 0000000..ae709b0 --- /dev/null +++ b/src/addresses/dto/get-suggestions.dto.ts @@ -0,0 +1,59 @@ +import { + IsBoolean, + IsEnum, + IsOptional, + IsString, + MinLength, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose, Transform } from "class-transformer"; + +import { AddressProvider } from "../entities/suggestion.entity"; + +export class GetSuggestionsDto { + @Expose() + @ApiProperty({ + description: "Search query for address suggestions", + example: "Moscow, Tverskaya", + minLength: 3, + }) + @IsString() + @MinLength(3) + query: string; + + @Expose() + @ApiProperty({ + description: "Preferred provider for address suggestions", + enum: ["dadata", "google"], + default: "dadata", + required: false, + }) + @IsEnum(["dadata", "google"] as const) + @IsOptional() + provider?: AddressProvider; + + @Expose() + @ApiPropertyOptional() + @ApiProperty({ + description: "Response language (ISO 639-1)", + example: "ru", + default: "ru", + required: false, + }) + @IsString() + @IsOptional() + language?: string; + + @Expose() + @ApiPropertyOptional() + @ApiProperty({ + description: "Include raw provider response in the result", + example: false, + default: false, + required: false, + }) + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value === "true" || value === true) + includeRaw?: boolean; +} diff --git a/src/addresses/entities/suggestion.entity.ts b/src/addresses/entities/suggestion.entity.ts new file mode 100644 index 0000000..aba970c --- /dev/null +++ b/src/addresses/entities/suggestion.entity.ts @@ -0,0 +1,268 @@ +import { IsOptional } from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export interface IGoogleRawResult { + address_components: { + long_name: string; + short_name: string; + types: string[]; + }[]; + formatted_address: string; + geometry: { + location: { + lat: number; + lng: number; + }; + location_type: string; + viewport: { + northeast: { + lat: number; + lng: number; + }; + southwest: { + lat: number; + lng: number; + }; + }; + }; + place_id: string; + types: string[]; +} + +export interface IDadataRawResult { + value: string; + unrestricted_value: string; + data: { + postal_code: string; + country: string; + country_iso_code: string; + federal_district: string; + region_fias_id: string; + region_kladr_id: string; + region_iso_code: string; + region_with_type: string; + region_type: string; + region_type_full: string; + region: string; + area_fias_id: string; + area_kladr_id: string; + area_with_type: string; + area_type: string; + area_type_full: string; + area: string; + city_fias_id: string; + city_kladr_id: string; + city_with_type: string; + city_type: string; + city_type_full: string; + city: string; + city_district_fias_id: string; + city_district_with_type: string; + city_district_type: string; + city_district_type_full: string; + city_district: string; + street_fias_id: string; + street_kladr_id: string; + street_with_type: string; + street_type: string; + street_type_full: string; + street: string; + house_fias_id: string; + house_kladr_id: string; + house_type: string; + house_type_full: string; + house: string; + block_type: string; + block_type_full: string; + block: string; + flat_type: string; + flat_type_full: string; + flat: string; + fias_id: string; + fias_level: string; + kladr_id: string; + geoname_id: string; + capital_marker: "0" | "1" | "2" | "3" | "4"; + okato: string; + oktmo: string; + tax_office: string; + tax_office_legal: string; + geo_lat: string; + geo_lon: string; + }; +} + +export type AddressProvider = "google" | "dadata"; + +export interface IAddressSuggestion { + // Unified fields + value: string; // Main display value + unrestricted_value?: string; // Full address value + + // Location data + coordinates?: { + lat: number; + lng: number; + }; + + // Structured address components + components: { + country?: string; + region?: string; + city?: string; + district?: string; + street?: string; + house?: string; + block?: string; + flat?: string; + postal_code?: string; + }; + + // Identifiers + fias_id?: string; + kladr_id?: string; + place_id?: string; + + // Metadata + provider: AddressProvider; + raw?: IGoogleRawResult | IDadataRawResult; +} + +export class AddressComponents { + @ApiProperty({ + description: "Country name", + example: "Россия", + required: false, + }) + country?: string; + + @ApiProperty({ + description: "Region name", + example: "г Москва", + required: false, + }) + region?: string; + + @ApiProperty({ + description: "City name", + example: "г Москва", + required: false, + }) + city?: string; + + @ApiProperty({ + description: "District name", + example: "р-н Тверской", + required: false, + }) + district?: string; + + @ApiProperty({ + description: "Street name", + example: "ул Тверская", + required: false, + }) + street?: string; + + @ApiProperty({ description: "House number", example: "1", required: false }) + house?: string; + + @ApiProperty({ + description: "Block/building number", + example: "1", + required: false, + }) + block?: string; + + @ApiProperty({ + description: "Apartment/office number", + example: "123", + required: false, + }) + flat?: string; + + @ApiProperty({ + description: "Postal code", + example: "125009", + required: false, + }) + postal_code?: string; +} + +export class Coordinates { + @Expose() + @ApiProperty({ description: "Latitude", example: 55.7558 }) + lat: number; + + @Expose() + @ApiProperty({ description: "Longitude", example: 37.6173 }) + lng: number; +} + +export class AddressSuggestion implements IAddressSuggestion { + @Expose() + @ApiProperty({ + description: "Main display value", + example: "г Москва, ул Тверская, д 1", + }) + value: string; + + @Expose() + @ApiProperty({ + description: "Full address value", + example: "125009, г Москва, ул Тверская, д 1", + required: false, + }) + unrestricted_value?: string; + + @Expose() + @ApiProperty({ + description: "Geographic coordinates", + type: Coordinates, + required: false, + }) + coordinates?: Coordinates; + + @ApiProperty({ + description: "Structured address components", + type: AddressComponents, + }) + components: AddressComponents; + + @ApiProperty({ + description: "FIAS identifier", + example: "8f41253d-6e3b-48a9-842a-25ba894bd093", + required: false, + }) + fias_id?: string; + + @ApiProperty({ + description: "KLADR identifier", + example: "7700000000000", + required: false, + }) + kladr_id?: string; + + @ApiProperty({ + description: "Google Place ID", + example: "ChIJyX6mjwXKUjoR1KiaB9_KTTY", + required: false, + }) + place_id?: string; + + @ApiProperty({ + description: "Address provider", + enum: ["dadata", "google"], + }) + provider: AddressProvider; + + @ApiPropertyOptional() + @ApiProperty({ + description: "Original response from the provider", + type: "object", + required: false, + }) + @IsOptional() + raw?: IGoogleRawResult | IDadataRawResult; +} diff --git a/src/addresses/services/addresses.service.ts b/src/addresses/services/addresses.service.ts new file mode 100644 index 0000000..66c2949 --- /dev/null +++ b/src/addresses/services/addresses.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common"; + +import { + AddressProvider, + IAddressSuggestion, +} from "../entities/suggestion.entity"; + +import { DadataService } from "./dadata.service"; +import { GoogleService } from "./google.service"; + +@Injectable() +export class AddressesService { + constructor( + private readonly dadataService: DadataService, + private readonly googleService: GoogleService, + ) {} + + public async getSuggestions( + query: string, + preferredProvider: AddressProvider = "dadata", + language = "ru", + includeRaw = false, + ): Promise { + if (preferredProvider === "dadata") { + return this.dadataService.getSuggestions(query, language, includeRaw); + } else { + return this.googleService.getSuggestions(query, language, includeRaw); + } + } +} diff --git a/src/addresses/services/dadata.service.ts b/src/addresses/services/dadata.service.ts new file mode 100644 index 0000000..76f98e7 --- /dev/null +++ b/src/addresses/services/dadata.service.ts @@ -0,0 +1,77 @@ +import env from "@core/env"; +import { HttpService } from "@nestjs/axios"; +import { Injectable } from "@nestjs/common"; +import { firstValueFrom } from "rxjs"; + +import { + IAddressSuggestion, + IDadataRawResult, +} from "../entities/suggestion.entity"; + +interface DadataResponse { + suggestions: IDadataRawResult[]; +} + +@Injectable() +export class DadataService { + private readonly API_URL = + "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"; + private readonly API_TOKEN = env.DADATA_API_TOKEN; + + constructor(private readonly httpService: HttpService) {} + + public async getSuggestions( + query: string, + language?: string, + includeRaw = false, + ): Promise { + const response = await firstValueFrom( + this.httpService.post( + this.API_URL, + { query, language: language === "ru" ? "ru" : "en" }, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Token ${this.API_TOKEN}`, + "X-Language": language === "ru" ? "ru" : "en", + }, + }, + ), + ); + + return this.transformSuggestions(response.data.suggestions, includeRaw); + } + + private transformSuggestions( + suggestions: IDadataRawResult[], + includeRaw: boolean, + ): IAddressSuggestion[] { + return suggestions.map((suggestion) => ({ + value: suggestion.value, + unrestricted_value: suggestion.unrestricted_value, + coordinates: + suggestion.data.geo_lat && suggestion.data.geo_lon + ? { + lat: parseFloat(suggestion.data.geo_lat), + lng: parseFloat(suggestion.data.geo_lon), + } + : undefined, + components: { + country: suggestion.data.country, + region: suggestion.data.region_with_type, + city: suggestion.data.city_with_type, + district: suggestion.data.city_district_with_type, + street: suggestion.data.street_with_type, + house: suggestion.data.house, + block: suggestion.data.block, + flat: suggestion.data.flat, + postal_code: suggestion.data.postal_code, + }, + fias_id: suggestion.data.fias_id, + kladr_id: suggestion.data.kladr_id, + provider: "dadata", + ...(includeRaw === true && { raw: suggestion }), + })); + } +} diff --git a/src/addresses/services/google.service.ts b/src/addresses/services/google.service.ts new file mode 100644 index 0000000..f1a679d --- /dev/null +++ b/src/addresses/services/google.service.ts @@ -0,0 +1,89 @@ +import env from "@core/env"; +import { HttpService } from "@nestjs/axios"; +import { Injectable } from "@nestjs/common"; +import { firstValueFrom } from "rxjs"; + +import { + IAddressSuggestion, + IGoogleRawResult, +} from "../entities/suggestion.entity"; + +interface GoogleGeocodingResponse { + results: IGoogleRawResult[]; + status: + | "OK" + | "ZERO_RESULTS" + | "OVER_QUERY_LIMIT" + | "REQUEST_DENIED" + | "INVALID_REQUEST" + | "UNKNOWN_ERROR"; +} + +@Injectable() +export class GoogleService { + private readonly API_URL = + "https://maps.googleapis.com/maps/api/geocode/json"; + private readonly API_KEY = env.GOOGLE_MAPS_API_KEY; + + constructor(private readonly httpService: HttpService) {} + + public async getSuggestions( + query: string, + language = "ru", + includeRaw = false, + ): Promise { + const response = await firstValueFrom( + this.httpService.get(this.API_URL, { + params: { + address: query, + key: this.API_KEY, + language, + }, + }), + ); + + return this.transformResults(response.data.results, includeRaw); + } + + private transformResults( + results: IGoogleRawResult[], + includeRaw: boolean, + ): IAddressSuggestion[] { + return results.map((result) => { + const components: { [key: string]: string } = {}; + + result.address_components.forEach((component) => { + if (component.types.includes("country")) { + components.country = component.long_name; + } + if (component.types.includes("administrative_area_level_1")) { + components.region = component.long_name; + } + if (component.types.includes("locality")) { + components.city = component.long_name; + } + if (component.types.includes("sublocality")) { + components.district = component.long_name; + } + if (component.types.includes("route")) { + components.street = component.long_name; + } + if (component.types.includes("street_number")) { + components.house = component.long_name; + } + if (component.types.includes("postal_code")) { + components.postal_code = component.long_name; + } + }); + + return { + value: result.formatted_address, + coordinates: result.geometry.location, + components, + place_id: result.place_id, + provider: "google" as const, + ...(includeRaw === true && { raw: result }), + }; + }); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 15cac54..e44e971 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,17 +1,48 @@ -import { AllExceptionsFilter } from "@core/errors/filter"; -import { RolesGuard } from "@core/guards/roles.guard"; +import * as path from "path"; + +import env from "@core/env"; +import { RedisModule } from "@liaoliaots/nestjs-redis"; +import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; -import { APP_FILTER, APP_GUARD, APP_PIPE } from "@nestjs/core"; +import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; import { MongooseModule } from "@nestjs/mongoose"; import { ThrottlerModule } from "@nestjs/throttler"; +import { NestjsFormDataModule } from "nestjs-form-data"; +import { + AcceptLanguageResolver, + HeaderResolver, + I18nModule, + QueryResolver, +} from "nestjs-i18n"; import { ZodValidationPipe } from "nestjs-zod"; +import { AuditLogsInterceptor } from "src/@base/audit-logs/audit-logs.interceptor"; +import { AuditLogsModule } from "src/@base/audit-logs/audit-logs.module"; +import { CacheInterceptor } from "src/@base/cache/cache.interceptor"; +import { CacheModule } from "src/@base/cache/cache.module"; +import { EncryptionModule } from "src/@base/encryption/encryption.module"; +import { RedisChannels } from "src/@base/redis/channels"; +import { RedlockModule } from "src/@base/redlock/redlock.module"; +import { S3Module } from "src/@base/s3/s3.module"; +import { SnapshotsModule } from "src/@base/snapshots/snapshots.module"; +import { SocketModule } from "src/@socket/socket.module"; +import { AddressesModule } from "src/addresses/addresses.module"; +import { DiscountsModule } from "src/discounts/discounts.module"; +import { DishCategoriesModule } from "src/dish-categories/dish-categories.module"; +import { DishesModule } from "src/dishes/dishes.module"; +import { DishesMenusModule } from "src/dishes-menus/dishes-menus.module"; +import { FilesModule } from "src/files/files.module"; +import { GuestsModule } from "src/guests/guests.module"; +import { OrdersModule } from "src/orders/orders.module"; +import { PaymentMethodsModule } from "src/payment-methods/payment-methods.module"; +import { RestaurantGuard } from "src/restaurants/@/guards/restaurant.guard"; +import { TimezonesModule } from "src/timezones/timezones.module"; +import { WorkshiftsModule } from "src/workshifts/workshifts.module"; +import { DrizzleModule } from "./@base/drizzle/drizzle.module"; import { AuthModule } from "./auth/auth.module"; import { SessionAuthGuard } from "./auth/guards/session-auth.guard"; -import { DrizzleModule } from "./drizzle/drizzle.module"; import { RestaurantsModule } from "./restaurants/restaurants.module"; -import { SessionsService } from "./sessions/sessions.service"; import { WorkersModule } from "./workers/workers.module"; @Module({ @@ -26,6 +57,8 @@ import { WorkersModule } from "./workers/workers.module"; ], }), DrizzleModule, + SnapshotsModule, + AuditLogsModule, MongooseModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -33,16 +66,51 @@ import { WorkersModule } from "./workers/workers.module"; uri: configService.get("MONGO_URL"), }), }), + RedisModule.forRoot({ + config: { + url: `${env.REDIS_URL}/${RedisChannels.COMMON}`, + }, + }), + BullModule.forRoot({ + prefix: "toite", + connection: { + url: `${env.REDIS_URL}/${RedisChannels.BULLMQ}`, + }, + }), + EncryptionModule, + TimezonesModule, AuthModule, WorkersModule, + RedlockModule, RestaurantsModule, + AddressesModule, + GuestsModule, + DishesMenusModule, + DishesModule, + DishCategoriesModule, + S3Module, + FilesModule, + NestjsFormDataModule, + OrdersModule, + CacheModule, + I18nModule.forRoot({ + fallbackLanguage: "en", + loaderOptions: { + path: path.join(__dirname, "/i18n/messages/"), + watch: true, + }, + resolvers: [ + { use: QueryResolver, options: ["lang"] }, + new HeaderResolver(["x-lang"]), + AcceptLanguageResolver, + ], + }), + SocketModule, + PaymentMethodsModule, + DiscountsModule, + WorkshiftsModule, ], providers: [ - { - provide: APP_FILTER, - useClass: AllExceptionsFilter, - }, - SessionsService, { provide: APP_PIPE, useClass: ZodValidationPipe, @@ -51,9 +119,17 @@ import { WorkersModule } from "./workers/workers.module"; provide: APP_GUARD, useClass: SessionAuthGuard, }, + { + provide: APP_INTERCEPTOR, + useClass: AuditLogsInterceptor, + }, { provide: APP_GUARD, - useClass: RolesGuard, + useClass: RestaurantGuard, + }, + { + provide: APP_INTERCEPTOR, + useClass: CacheInterceptor, }, ], }) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 49f1979..e7dbe57 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,14 +1,26 @@ +import "dotenv/config"; +import env from "@core/env"; import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; import { DrizzleModule } from "@postgress-db/drizzle.module"; -import { SessionsService } from "src/sessions/sessions.service"; +import { RedlockModule } from "src/@base/redlock/redlock.module"; import { WorkersModule } from "src/workers/workers.module"; import { AuthController } from "./controllers/auth.controller"; import { AuthService } from "./services/auth.service"; +import { SessionsService } from "./services/sessions.service"; @Module({ - imports: [DrizzleModule, WorkersModule], + imports: [ + DrizzleModule, + WorkersModule, + JwtModule.register({ + secret: env.JWT.SECRET, + }), + RedlockModule, + ], providers: [AuthService, SessionsService], controllers: [AuthController], + exports: [AuthService, SessionsService], }) export class AuthModule {} diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts index c2ac960..3833ca1 100644 --- a/src/auth/auth.types.ts +++ b/src/auth/auth.types.ts @@ -1,3 +1,5 @@ +import { IWorker, IWorkersToRestaurants } from "@postgress-db/schema/workers"; + export enum AUTH_STRATEGY { accessToken = "access token", refreshToken = "refresh token", @@ -6,3 +8,14 @@ export enum AUTH_STRATEGY { export enum AUTH_COOKIES { token = "toite-auth-token", } + +export type SessionTokenPayload = { + sessionId: string; + workerId: string; + worker: Pick & { + workersToRestaurants: Pick[]; + }; + httpAgent: string; + ip: string; + version: number; +}; diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 891b11c..2f700a2 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,17 +1,19 @@ -import { IncomingHttpHeaders } from "http2"; - import { Controller } from "@core/decorators/controller.decorator"; import { Cookies } from "@core/decorators/cookies.decorator"; +import IpAddress from "@core/decorators/ip-address.decorator"; +import UserAgent from "@core/decorators/user-agent.decorator"; import { Worker } from "@core/decorators/worker.decorator"; +import env from "@core/env"; +import { RequestWorker } from "@core/interfaces/request"; +import { Response } from "@core/interfaces/response"; import { Body, Delete, Get, - Headers, HttpCode, HttpStatus, - Ip, Post, + Res, } from "@nestjs/common"; import { ApiForbiddenResponse, @@ -19,13 +21,12 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { IWorker } from "@postgress-db/schema"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; import { Serializable } from "src/@core/decorators/serializable.decorator"; import { WorkerEntity } from "src/workers/entities/worker.entity"; import { AUTH_COOKIES } from "../auth.types"; -import { RequireSessionAuth } from "../decorators/session-auth.decorator"; -import { SetCookies } from "../decorators/set-cookie.decorator"; +import { Public } from "../decorators/public.decorator"; import { SignInDto } from "../dto/req/sign-in.dto"; import { AuthService } from "../services/auth.service"; @@ -33,7 +34,6 @@ import { AuthService } from "../services/auth.service"; export class AuthController { constructor(private authService: AuthService) {} - @RequireSessionAuth() @Get("user") @HttpCode(HttpStatus.OK) @Serializable(WorkerEntity) @@ -47,12 +47,19 @@ export class AuthController { @ApiUnauthorizedResponse({ description: "You unauthorized", }) - async getUser(@Worker() worker: IWorker) { - return worker; + async getUser(@Worker() worker: RequestWorker) { + return { + ...worker, + restaurants: worker.workersToRestaurants.map((r) => ({ + ...r, + restaurantName: "", + })), + } satisfies Omit; } + @Public() + @EnableAuditLog() @Post("sign-in") - @SetCookies() @HttpCode(HttpStatus.OK) @Serializable(WorkerEntity) @ApiOperation({ @@ -67,26 +74,33 @@ export class AuthController { }) async signIn( @Body() dto: SignInDto, - @Ip() ipAddress: string, - @Headers() headers: IncomingHttpHeaders, + @IpAddress() ip: string, + @UserAgent() httpAgent: string, + @Res({ passthrough: true }) + res: Response, ) { const worker = await this.authService.signIn(dto); - if (!ipAddress) ipAddress = "N/A"; + if (!ip) ip = "N/A"; - const session = await this.authService.createSession({ - headers, - ipAddress, + const signedJWT = await this.authService.createSignedSession({ + httpAgent, + ip, worker, }); + res.setCookie(AUTH_COOKIES.token, signedJWT, { + maxAge: 60 * 60 * 24 * 365, // 1 year + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + path: "/", + }); + return { ...worker, - setSessionToken: session.token, }; } - @RequireSessionAuth() - @SetCookies() @Delete("sign-out") @HttpCode(HttpStatus.OK) @Serializable(class Empty {}) @@ -99,11 +113,18 @@ export class AuthController { @ApiUnauthorizedResponse({ description: "You unauthorized", }) - async signOut(@Cookies(AUTH_COOKIES.token) token: string) { - await this.authService.destroySession(token); + async signOut( + @Cookies(AUTH_COOKIES.token) token: string, + @Res({ + passthrough: true, + }) + res: Response, + ) { + if (!token) return {}; + // await this.authService.destroySession(token); - return { - setSessionToken: null, - }; + res.clearCookie(AUTH_COOKIES.token); + + return {}; } } diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..2b01778 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { applyDecorators, SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; +export const Public = () => applyDecorators(SetMetadata(IS_PUBLIC_KEY, true)); diff --git a/src/auth/decorators/session-auth.decorator.ts b/src/auth/decorators/session-auth.decorator.ts deleted file mode 100644 index 3d631f5..0000000 --- a/src/auth/decorators/session-auth.decorator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SetMetadata } from "@nestjs/common"; - -export const REQUIRE_SESSION_AUTH_KEY = "session-auth-requirement"; -export const RequireSessionAuth = () => - SetMetadata(REQUIRE_SESSION_AUTH_KEY, true); diff --git a/src/auth/decorators/set-cookie.decorator.ts b/src/auth/decorators/set-cookie.decorator.ts deleted file mode 100644 index 83d6ba1..0000000 --- a/src/auth/decorators/set-cookie.decorator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { applyDecorators, UseInterceptors } from "@nestjs/common"; - -import { SetCookiesInterceptor } from "../interceptors/set-cookie.interceptor"; - -/** - * Decorator which encapsulates setting cookies decorators - * @see [Decorator composition - NestJS](https://docs.nestjs.com/custom-decorators#decorator-composition) - */ -export function SetCookies() { - return applyDecorators(UseInterceptors(SetCookiesInterceptor)); -} diff --git a/src/auth/dto/req/sign-in.dto.ts b/src/auth/dto/req/sign-in.dto.ts index 5ac3360..32194ec 100644 --- a/src/auth/dto/req/sign-in.dto.ts +++ b/src/auth/dto/req/sign-in.dto.ts @@ -1,6 +1,6 @@ +import { IsString } from "@i18n-class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsString } from "class-validator"; import { z } from "zod"; export const signInSchema = z.object({ @@ -13,7 +13,7 @@ export class SignInDto implements z.infer { @Expose() @ApiProperty({ description: "Login of the worker", - example: "dana.keller", + example: "vi.keller", }) login: string; diff --git a/src/auth/guards/session-auth.guard.ts b/src/auth/guards/session-auth.guard.ts index 780bed1..590d6c3 100644 --- a/src/auth/guards/session-auth.guard.ts +++ b/src/auth/guards/session-auth.guard.ts @@ -1,91 +1,131 @@ +import env from "@core/env"; import { UnauthorizedException } from "@core/errors/exceptions/unauthorized.exception"; import { Request } from "@core/interfaces/request"; import { Response } from "@core/interfaces/response"; -import * as ms from "@lukeed/ms"; -import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, +} from "@nestjs/common"; import { Reflector } from "@nestjs/core"; -import { SessionsService } from "src/sessions/sessions.service"; -import { WorkersService } from "src/workers/workers.service"; - -import { AUTH_COOKIES } from "../auth.types"; -import { REQUIRE_SESSION_AUTH_KEY } from "../decorators/session-auth.decorator"; +import * as requestIp from "@supercharge/request-ip"; +import { RedlockService } from "src/@base/redlock/redlock.service"; +import { AUTH_COOKIES } from "src/auth/auth.types"; +import { IS_PUBLIC_KEY } from "src/auth/decorators/public.decorator"; +import { SessionsService } from "src/auth/services/sessions.service"; @Injectable() export class SessionAuthGuard implements CanActivate { + private readonly logger = new Logger(SessionAuthGuard.name); + constructor( - private readonly sessionsService: SessionsService, - private readonly workersService: WorkersService, private readonly reflector: Reflector, + private readonly sessionsService: SessionsService, + private readonly redlockService: RedlockService, ) {} - async canActivate(context: ExecutionContext) { - const isSessionRequired = this.reflector.getAllAndOverride( - REQUIRE_SESSION_AUTH_KEY, - [context.getHandler(), context.getClass()], - ); - - if (!isSessionRequired) return true; - - const req = context.switchToHttp().getRequest() as Request; - const res = context.switchToHttp().getResponse() as Response; + private getUserIp(req: Request) { + return requestIp.getClientIp(req); + } - const ip = req.ip; - const token = req.cookies?.[AUTH_COOKIES.token]; - const httpAgent = String( + private getUserAgent(req: Request) { + return String( req.headers["user-agent"] || req.headers["User-Agent"] || "N/A", ); + } - if (!token) { - throw new UnauthorizedException(); - } + private getCookie(req: Request, cookieName: string) { + return req.cookies?.[cookieName]; + } - const isValid = await this.sessionsService.isSessionValid(token); + private async _isPublic(context: ExecutionContext) { + const isPublic = !!this.reflector.get(IS_PUBLIC_KEY, context.getHandler()); - if (!isValid) { - throw new UnauthorizedException(); - } + return isPublic; + } - const session = await this.sessionsService.findByToken(token); + private async _isRefreshDisabled(req: Request) { + return !!req.headers["x-disable-session-refresh"]; + } - const isCompromated = - session.ipAddress !== ip || session.httpAgent !== httpAgent; + private async _handleSession(req: Request, res: Response) { + const jwtSign = this.getCookie(req, AUTH_COOKIES.token); - if (isCompromated) { - // TODO: Implement logic for notification about compromated session - throw new UnauthorizedException("Session is compromated"); - } + if (!jwtSign) throw new UnauthorizedException(); - const isTimeToRefresh = - new Date(session.refreshedAt).getTime() + - ms.parse(process.env?.SESSION_EXPIRES_IN || "30m") < - new Date().getTime(); + const httpAgent = this.getUserAgent(req); + const ip = this.getUserIp(req); - if (isTimeToRefresh) { - const newToken = await this.sessionsService.refresh(token); + const session = await this.sessionsService.validateSession(jwtSign, { + httpAgent, + ip, + }); - res.cookie(AUTH_COOKIES.token, newToken, { - maxAge: ms.parse("1y"), - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - }); - } + if (!session) throw new UnauthorizedException(); - const worker = await this.workersService.findById(session.workerId); + req.session = session; + req.worker = session?.worker ?? null; + + const isRefreshDisabled = await this._isRefreshDisabled(req); + const isRequireRefresh = + this.sessionsService.isSessionRequireRefresh(session); + + if (isRequireRefresh && !isRefreshDisabled) { + let lock; + try { + // Try to acquire the lock with no retries and minimal retry delay + lock = await this.redlockService.acquire( + [`session-refresh:${session.id}`], + 10000, // 10 second lock duration + { + retryCount: 0, // Don't retry if lock can't be acquired + retryDelay: 0, + }, + ); + } catch (error) { + // If we couldn't acquire the lock, another request is handling the refresh + // Just continue without refreshing + return true; + } + + try { + const newSignedJWT = await this.sessionsService.refreshSignedSession( + jwtSign, + { + httpAgent, + ip: ip ?? "N/A", + }, + ); + + res.setCookie(AUTH_COOKIES.token, newSignedJWT, { + maxAge: 60 * 60 * 24 * 365, // 1 year + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + path: "/", + }); + } finally { + if (lock) { + await this.redlockService.release(lock); + } + } + } - const isTimeToUpdateOnline = - !worker.onlineAt || - new Date(worker.onlineAt).getTime() + ms.parse("5m") < - new Date().getTime(); + return true; + } - if (isTimeToUpdateOnline) { - await this.workersService.update(worker.id, { - onlineAt: new Date(), - }); + async canActivate(context: ExecutionContext) { + // If session is not required, we can activate the guard from start + if (await this._isPublic(context)) { + return true; } - req.session = session; - req.worker = { ...worker, passwordHash: undefined }; + const req = context.switchToHttp().getRequest() as Request; + const res = context.switchToHttp().getResponse() as Response; + + // Check if valid, throw if not and perform update if needed + await this._handleSession(req, res); return true; } diff --git a/src/auth/interceptors/set-cookie.interceptor.ts b/src/auth/interceptors/set-cookie.interceptor.ts deleted file mode 100644 index 165e12a..0000000 --- a/src/auth/interceptors/set-cookie.interceptor.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Response } from "@core/interfaces/response"; -import * as ms from "@lukeed/ms"; -import { - CallHandler, - ExecutionContext, - Injectable, - NestInterceptor, -} from "@nestjs/common"; -import { Observable } from "rxjs"; -import { map } from "rxjs/operators"; - -import { AUTH_COOKIES } from "../auth.types"; - -/** - * Set cookies interceptor sets cookies parameters to response - * @see [Interceptors - NestJS](https://docs.nestjs.com/interceptors) - * @see [Cookies - NestJS](https://docs.nestjs.com/techniques/cookies) - */ -@Injectable() -export class SetCookiesInterceptor implements NestInterceptor { - /** - * Implements interception logic - * @param context Execution context which describes current request pipeline - * @param next Object which provides access to response RxJS stream - */ - public intercept( - context: ExecutionContext, - next: CallHandler, - ): Observable { - return next.handle().pipe( - map((data: Partial<{ setSessionToken?: string }>) => { - const res = context.switchToHttp().getResponse() as Response; - - if (typeof data?.setSessionToken === "string") { - res.cookie(AUTH_COOKIES.token, data.setSessionToken, { - maxAge: ms.parse("1y"), - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - }); - } - - if (data?.setSessionToken === null) { - res.clearCookie(AUTH_COOKIES.token); - } - - return data; - }), - ); - } -} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 82034d0..31097ad 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,19 +1,23 @@ -import { IncomingHttpHeaders } from "http"; - import { UnauthorizedException } from "@core/errors/exceptions/unauthorized.exception"; -import { Injectable } from "@nestjs/common"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { workers } from "@postgress-db/schema/workers"; import * as argon2 from "argon2"; -import { SessionsService } from "src/sessions/sessions.service"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { SignInDto } from "src/auth/dto/req/sign-in.dto"; +import { PG_CONNECTION } from "src/constants"; import { WorkerEntity } from "src/workers/entities/worker.entity"; import { WorkersService } from "src/workers/workers.service"; -import { SignInDto } from "../dto/req/sign-in.dto"; +import { SessionsService } from "./sessions.service"; @Injectable() export class AuthService { constructor( - private workersService: WorkersService, + private readonly workersService: WorkersService, private readonly sessionsService: SessionsService, + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} public async signIn(dto: SignInDto): Promise { @@ -22,38 +26,62 @@ export class AuthService { const worker = await this.workersService.findOneByLogin(login); if (!worker) { - throw new UnauthorizedException("User not found"); + throw new UnauthorizedException("errors.auth.invalid-credentials"); } // TODO: Implement logic for timeout in case of wrong password if (!(await argon2.verify(worker.passwordHash, password))) { - throw new UnauthorizedException("Wrong password"); + throw new UnauthorizedException("errors.auth.invalid-credentials"); } return worker; } - public async createSession(data: { + public async createSignedSession(data: { worker: WorkerEntity; - headers: IncomingHttpHeaders; - ipAddress: string; + httpAgent: string; + ip: string; }) { - const { worker, headers, ipAddress } = data; - - const httpAgent = String( - headers["user-agent"] || headers["User-Agent"] || "N/A", - ); + return this.sessionsService.createSignedSession(data); + } - const created = await this.sessionsService.create({ - workerId: worker.id, - httpAgent, - ipAddress, - }); + public async refreshSignedSession( + signed: string, + options: { + httpAgent: string; + ip: string; + }, + ) { + return this.sessionsService.refreshSignedSession(signed, options); + } - return await this.sessionsService.findByToken(created.token); + public async validateSession( + signed: string, + options?: { + httpAgent?: string; + ip?: string; + }, + ) { + return this.sessionsService.validateSession(signed, options); } - public async destroySession(token: string) { - return await this.sessionsService.destroy(token); + public async getAuthWorker(workerId: string) { + return await this.pg.query.workers.findFirst({ + where: eq(workers.id, workerId), + with: { + workersToRestaurants: { + columns: { + restaurantId: true, + }, + }, + }, + columns: { + id: true, + name: true, + login: true, + role: true, + isBlocked: true, + }, + }); } } diff --git a/src/auth/services/sessions.service.ts b/src/auth/services/sessions.service.ts new file mode 100644 index 0000000..537561c --- /dev/null +++ b/src/auth/services/sessions.service.ts @@ -0,0 +1,267 @@ +import env from "@core/env"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { UnauthorizedException } from "@core/errors/exceptions/unauthorized.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { schema } from "@postgress-db/drizzle.module"; +import { ISession, sessions } from "@postgress-db/schema/sessions"; +import { workers } from "@postgress-db/schema/workers"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { SessionTokenPayload } from "src/auth/auth.types"; +import { PG_CONNECTION } from "src/constants"; +import { v4 as uuidv4 } from "uuid"; + +export const SESSION_TOKEN_GRACE_PERIOD = env.JWT.GRACE_PERIOD; +export const SESSION_TOKEN_REFRESH_INTERVAL = env.JWT.REFRESH_INTERVAL; +export const SESSION_TOKEN_EXPIRES_IN = env.JWT.EXPIRES_IN; + +@Injectable() +export class SessionsService { + constructor( + private readonly jwtService: JwtService, + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + private async _getWorker(workerId: string) { + const worker = await this.pg.query.workers.findFirst({ + where: eq(workers.id, workerId), + with: { + workersToRestaurants: { + columns: { + restaurantId: true, + }, + }, + }, + columns: { + id: true, + name: true, + login: true, + role: true, + isBlocked: true, + }, + }); + + if (!worker) { + throw new BadRequestException(); + } + + return worker; + } + + public async createSignedSession(data: { + worker: { id: string }; + httpAgent: string; + ip: string; + }) { + const { worker, httpAgent, ip } = data; + + const { sessionId, signed } = await this.generateSignedSession({ + workerId: worker.id, + httpAgent, + ip, + }); + + await this.pg.insert(sessions).values({ + id: sessionId, + workerId: worker.id, + ip, + httpAgent, + }); + + return signed; + } + + public async refreshSignedSession( + signed: string, + options: { + httpAgent: string; + ip: string; + }, + ) { + const { httpAgent, ip } = options; + const decoded = await this.decodeSession(signed); + + if (!decoded) { + throw new UnauthorizedException(); + } + + const { sessionId, workerId } = decoded; + + const refreshed = await this.generateSignedSession({ + prevSign: signed, + workerId, + httpAgent, + ip, + }); + + await this.pg + .update(sessions) + .set({ + id: refreshed.sessionId, + previousId: sessionId, + refreshedAt: new Date(), + }) + .where(eq(sessions.id, sessionId)); + + return refreshed.signed; + } + + public generateSessionId() { + return `${uuidv4()}`; + } + + public async generateSignedSession(options: { + prevSign?: string; + workerId: string; + httpAgent: string; + ip: string; + }) { + const { prevSign, workerId, httpAgent, ip } = options; + + const previous = await this.decodeSession(prevSign); + + const sessionId = this.generateSessionId(); + const worker = await this._getWorker(workerId); + + const payload: SessionTokenPayload = { + sessionId, + workerId, + worker, + httpAgent, + ip, + version: previous ? previous.version + 1 : 1, + }; + + const signed = this.jwtService.sign(payload, { + expiresIn: SESSION_TOKEN_EXPIRES_IN, + }); + + return { + sessionId, + payload, + signed, + }; + } + + public async decodeSession( + signed?: string, + ): Promise { + if (!signed) { + return null; + } + + const decoded = this.jwtService.decode(signed); + + if (!decoded) { + return null; + } + + return decoded satisfies SessionTokenPayload; + } + + public async isSignValid(signed: string) { + try { + await this.jwtService.verify(signed); + return true; + } catch (error) { + return false; + } + } + + public isSessionExpired(session: Pick) { + const now = new Date(); + const refreshed = new Date(session.refreshedAt); + + return ( + now.getTime() - refreshed.getTime() > SESSION_TOKEN_EXPIRES_IN * 1000 + ); + } + + public isDateInGracePeriod(refreshedAt: Date | string) { + const now = new Date(); + const refreshed = new Date(refreshedAt); + + return ( + now.getTime() - refreshed.getTime() <= SESSION_TOKEN_GRACE_PERIOD * 1000 + ); + } + + public isSessionRequireRefresh(session: Pick) { + const now = new Date(); + const refreshed = new Date(session.refreshedAt); + + return ( + now.getTime() - refreshed.getTime() > + SESSION_TOKEN_REFRESH_INTERVAL * 1000 + ); + } + + public async validateSession( + signed: string, + options?: { + httpAgent?: string; + ip?: string; + }, + ) { + if (!(await this.isSignValid(signed))) { + return false; + } + + const decoded = await this.decodeSession(signed); + + if (!decoded) { + return false; + } + + const { sessionId, httpAgent } = decoded; + + if (options) { + if (!!options?.httpAgent && options.httpAgent !== httpAgent) { + return false; + } + } + + const session = await this.pg.query.sessions.findFirst({ + where: (sessions, { eq, or }) => + or(eq(sessions.id, sessionId), eq(sessions.previousId, sessionId)), + with: { + worker: { + with: { + workersToRestaurants: { + columns: { + restaurantId: true, + }, + }, + ownedRestaurants: { + columns: { + id: true, + }, + }, + }, + }, + }, + }); + + if (!session || !session.isActive) return false; + + if (session.id === sessionId) { + // Everything is ok + } else if (session.previousId === sessionId) { + if (!this.isDateInGracePeriod(session.refreshedAt)) { + return false; + } + } else { + return false; + } + + if (this.isSessionExpired(session)) { + return false; + } + + // @ts-expect-error dummy error + delete session.worker.passwordHash; + + return session; + } +} diff --git a/src/discounts/discounts.controller.ts b/src/discounts/discounts.controller.ts new file mode 100644 index 0000000..c584e0d --- /dev/null +++ b/src/discounts/discounts.controller.ts @@ -0,0 +1,95 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { Body, Get, Param, Patch, Post } from "@nestjs/common"; +import { + ApiCreatedResponse, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { CreateDiscountDto } from "src/discounts/dto/create-discount.dto"; +import { + DiscountEntity, + DiscountFullEntity, +} from "src/discounts/entities/discount.entity"; + +import { UpdateDiscountDto } from "./dto/update-discount.dto"; +import { DiscountsService } from "./services/discounts.service"; + +@Controller("discounts") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class DiscountsController { + constructor(private readonly discountsService: DiscountsService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @ApiOperation({ + summary: "Get all discounts", + }) + @ApiOkResponse({ + description: "Discounts have been successfully fetched", + type: DiscountEntity, + }) + async findAll(@Worker() worker: RequestWorker) { + return this.discountsService.findMany({ worker }); + } + + @EnableAuditLog() + @Get(":id") + @Serializable(DiscountFullEntity) + @ApiOperation({ + summary: "Get a discount by id", + description: "Get a discount by id", + }) + @ApiOkResponse({ + description: "Discount has been successfully fetched", + type: DiscountFullEntity, + }) + async findOne(@Param("id") id: string, @Worker() worker: RequestWorker) { + return this.discountsService.findOne(id, { worker }); + } + + @EnableAuditLog() + @Post() + @Serializable(DiscountEntity) + @ApiOperation({ + summary: "Create a new discount", + }) + @ApiCreatedResponse({ + description: "Discount has been successfully created", + type: DiscountEntity, + }) + async create( + @Body() payload: CreateDiscountDto, + @Worker() worker: RequestWorker, + ) { + return this.discountsService.create(payload, { + worker, + }); + } + + @EnableAuditLog() + @Patch(":id") + @Serializable(DiscountEntity) + @ApiOperation({ + summary: "Update an existing discount", + }) + @ApiOkResponse({ + description: "Discount has been successfully updated", + type: DiscountEntity, + }) + async update( + @Param("id") id: string, + @Body() payload: UpdateDiscountDto, + @Worker() worker: RequestWorker, + ) { + return this.discountsService.update(id, payload, { + worker, + }); + } +} diff --git a/src/discounts/discounts.module.ts b/src/discounts/discounts.module.ts new file mode 100644 index 0000000..eb9e965 --- /dev/null +++ b/src/discounts/discounts.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { DiscountsController } from "src/discounts/discounts.controller"; +import { DiscountsService } from "src/discounts/services/discounts.service"; +import { OrderDiscountsService } from "src/discounts/services/order-discounts.service"; + +@Module({ + imports: [DrizzleModule], + controllers: [DiscountsController], + providers: [DiscountsService, OrderDiscountsService], + exports: [DiscountsService, OrderDiscountsService], +}) +export class DiscountsModule {} diff --git a/src/discounts/dto/create-discount.dto.ts b/src/discounts/dto/create-discount.dto.ts new file mode 100644 index 0000000..2ca6465 --- /dev/null +++ b/src/discounts/dto/create-discount.dto.ts @@ -0,0 +1,71 @@ +import { IsArray, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; + +import { DiscountEntity } from "../entities/discount.entity"; + +export class CreateDiscountMenuDto { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + dishesMenuId: string; + + @Expose() + @IsArray() + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: "Array of restaurant IDs where discount will be applied", + type: [String], + example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + }) + restaurantIds: string[]; + + @Expose() + @IsArray() + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: "Array of category IDs where discount will be applied", + type: [String], + example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + }) + categoryIds: string[]; +} + +export class CreateDiscountDto extends PickType(DiscountEntity, [ + "name", + "description", + "percent", + "orderFroms", + "orderTypes", + "daysOfWeek", + "promocode", + "applyOnlyAtFirstOrder", + "applyOnlyByPromocode", + "isEnabled", + "startTime", + "endTime", + "activeFrom", + "activeTo", +]) { + @Expose() + @IsArray() + @Type(() => CreateDiscountMenuDto) + @ApiProperty({ + description: "Array of menus where discount will be applied", + type: [CreateDiscountMenuDto], + }) + menus: CreateDiscountMenuDto[]; + + @Expose() + @IsArray() + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: "Array of guest IDs where discount will be applied", + type: [String], + example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + }) + guestIds: string[]; +} diff --git a/src/discounts/dto/update-discount.dto.ts b/src/discounts/dto/update-discount.dto.ts new file mode 100644 index 0000000..0ee1a5d --- /dev/null +++ b/src/discounts/dto/update-discount.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateDiscountDto } from "./create-discount.dto"; + +export class UpdateDiscountDto extends PartialType(CreateDiscountDto) {} diff --git a/src/discounts/entities/discount.entity.ts b/src/discounts/entities/discount.entity.ts new file mode 100644 index 0000000..d5110ed --- /dev/null +++ b/src/discounts/entities/discount.entity.ts @@ -0,0 +1,256 @@ +import { + IsArray, + IsBoolean, + IsDate, + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + Min, + MinLength, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { IDiscount } from "@postgress-db/schema/discounts"; +import { ZodDayOfWeekEnum } from "@postgress-db/schema/general"; +import { + ZodOrderFromEnum, + ZodOrderTypeEnum, +} from "@postgress-db/schema/order-enums"; +import { Expose, Type } from "class-transformer"; +import { i18nValidationMessage } from "nestjs-i18n"; +import { DishesMenuEntity } from "src/dishes-menus/entity/dishes-menu.entity"; +import { GuestEntity } from "src/guests/entities/guest.entity"; + +export class DiscountConnectionEntity { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + dishesMenuId: string; + + @Expose() + @Type(() => DishesMenuEntity) + @ApiProperty({ + description: "Dishes menu that was assigned to the discount", + type: DishesMenuEntity, + }) + dishesMenu: DishesMenuEntity; + + @IsArray() + @IsUUID(undefined, { each: true }) + @Expose() + @ApiProperty({ + description: "Restaurant IDs that were assigned to the discount", + type: String, + isArray: true, + }) + restaurantIds: string[]; + + @IsArray() + @IsUUID(undefined, { each: true }) + @Expose() + @ApiProperty({ + description: "Dish category IDs that were assigned to the discount", + type: String, + isArray: true, + }) + dishCategoryIds: string[]; +} + +export class DiscountGuestEntity extends PickType(GuestEntity, [ + "id", + "name", + "phone", +]) { + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when guest was created to discount", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + createdAt: Date; +} + +export class DiscountEntity implements IDiscount { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the discount", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsString() + @MinLength(2) + @Expose() + @ApiProperty({ + description: "Name of the discount", + example: "Happy Hour", + }) + name: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional({ + description: "Description of the discount", + example: "20% off during happy hours", + }) + description: string | null; + + @IsNumber() + @Min(0) + @Max(100) + @Expose() + @ApiProperty({ + description: "Percent of the discount", + example: 20, + }) + percent: number; + + @IsArray() + @IsEnum(ZodOrderFromEnum.Enum, { each: true }) + @Expose() + @ApiProperty({ + description: "Order sources where discount is applicable", + enum: ZodOrderFromEnum.Enum, + isArray: true, + }) + orderFroms: (typeof ZodOrderFromEnum._type)[]; + + @IsArray() + @IsEnum(ZodOrderTypeEnum.Enum, { each: true }) + @Expose() + @ApiProperty({ + description: "Order types where discount is applicable", + enum: ZodOrderTypeEnum.Enum, + isArray: true, + }) + orderTypes: (typeof ZodOrderTypeEnum._type)[]; + + @IsArray() + @IsEnum(ZodDayOfWeekEnum.Enum, { each: true }) + @Expose() + @ApiProperty({ + description: "Days of week when discount is active", + enum: ZodDayOfWeekEnum.Enum, + isArray: true, + }) + daysOfWeek: (typeof ZodDayOfWeekEnum._type)[]; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional({ + description: "Promocode for the discount", + example: "HAPPY20", + }) + promocode: string | null; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether discount applies only to first order", + example: false, + }) + applyOnlyAtFirstOrder: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether discount is applied by promocode", + example: true, + }) + applyOnlyByPromocode: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether discount is enabled", + example: true, + }) + isEnabled: boolean; + + @IsOptional() + @Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/, { + message: i18nValidationMessage("validation.time.invalid_format"), + }) + @IsString() + @Expose() + @ApiPropertyOptional({ + description: "Start time in HH:MM or HH:MM:SS format", + example: "14:00", + }) + startTime: string | null; + + @IsOptional() + @Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/, { + message: i18nValidationMessage("validation.time.invalid_format"), + }) + @IsString() + @Expose() + @ApiPropertyOptional({ + description: "End time in HH:MM or HH:MM:SS format", + example: "18:00", + }) + endTime: string | null; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Start date of the discount validity", + example: new Date("2024-01-01T00:00:00.000Z"), + }) + activeFrom: Date; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "End date of the discount validity", + example: new Date("2024-12-31T23:59:59.999Z"), + }) + activeTo: Date; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Timestamp when discount was created", + example: new Date("2024-01-01T00:00:00.000Z"), + }) + createdAt: Date; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Timestamp when discount was last updated", + example: new Date("2024-01-01T00:00:00.000Z"), + }) + updatedAt: Date; +} + +export class DiscountFullEntity extends DiscountEntity { + @IsArray() + @Expose() + @Type(() => DiscountConnectionEntity) + @ApiProperty({ + description: "Connections that was assigned to the discount", + type: [DiscountConnectionEntity], + }) + connections: DiscountConnectionEntity[]; + + @IsArray() + @Expose() + @Type(() => DiscountGuestEntity) + @ApiProperty({ + description: "Guests that was assigned to the discount", + type: [DiscountGuestEntity], + }) + guests: DiscountGuestEntity[]; +} diff --git a/src/discounts/services/discounts.service.ts b/src/discounts/services/discounts.service.ts new file mode 100644 index 0000000..3477335 --- /dev/null +++ b/src/discounts/services/discounts.service.ts @@ -0,0 +1,340 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { + discounts, + discountsConnections, + discountsToGuests, +} from "@postgress-db/schema/discounts"; +import { and, eq, exists, inArray, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { CreateDiscountDto } from "src/discounts/dto/create-discount.dto"; +import { UpdateDiscountDto } from "src/discounts/dto/update-discount.dto"; +import { + DiscountConnectionEntity, + DiscountEntity, + DiscountFullEntity, +} from "src/discounts/entities/discount.entity"; + +@Injectable() +export class DiscountsService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + public async findMany(options: { + worker?: RequestWorker; + }): Promise { + const { worker } = options; + + const conditions: SQL[] = []; + + // If worker is not system admin, check if they have access to the discounts + if ( + worker && + worker.role !== "SYSTEM_ADMIN" && + worker.role !== "CHIEF_ADMIN" + ) { + const restaurantIds = + worker.role === "OWNER" + ? worker.ownedRestaurants.map((r) => r.id) + : worker.workersToRestaurants.map((r) => r.restaurantId); + + conditions.push( + exists( + this.pg + .select({ id: discountsConnections.restaurantId }) + .from(discountsConnections) + .where(inArray(discountsConnections.restaurantId, restaurantIds)), + ), + ); + } + + const fetchedDiscounts = await this.pg.query.discounts.findMany({ + ...(conditions.length > 0 ? { where: () => and(...conditions) } : {}), + orderBy: (discounts, { desc }) => [desc(discounts.createdAt)], + }); + + return fetchedDiscounts.map(({ ...discount }) => ({ + ...discount, + })); + } + + public async findOne( + id: string, + options: { worker?: RequestWorker }, + ): Promise { + const { worker } = options; + + worker; + + const discount = await this.pg.query.discounts.findFirst({ + where: eq(discounts.id, id), + with: { + discountsToGuests: { + columns: { + createdAt: true, + }, + with: { + guest: { + columns: { + id: true, + name: true, + phone: true, + }, + }, + }, + }, + connections: { + with: { + dishesMenu: { + with: { + dishesMenusToRestaurants: { + columns: {}, + with: { + restaurant: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + owner: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!discount) { + return null; + } + + // Group connections by dishesMenuId + const groupedConnections = discount.connections.reduce( + (acc, connection) => { + const key = connection.dishesMenuId; + if (!acc[key]) { + acc[key] = { + dishesMenuId: connection.dishesMenuId, + dishesMenu: { + ...connection.dishesMenu, + restaurants: connection.dishesMenu.dishesMenusToRestaurants.map( + ({ restaurant }) => restaurant, + ), + }, + restaurantIds: [], + dishCategoryIds: [], + }; + } + acc[key].restaurantIds.push(connection.restaurantId); + acc[key].dishCategoryIds.push(connection.dishCategoryId); + return acc; + }, + {} as Record, + ); + + return { + ...discount, + connections: Object.values(groupedConnections).map((connection) => ({ + ...connection, + restaurantIds: [...new Set(connection.restaurantIds)], + dishCategoryIds: [...new Set(connection.dishCategoryIds)], + })), + guests: discount.discountsToGuests.map(({ guest, createdAt }) => ({ + ...guest, + createdAt, + })), + }; + } + + private async validatePayload( + payload: CreateDiscountDto | UpdateDiscountDto, + worker: RequestWorker, + ) { + if (!payload.menus || payload.menus.length === 0) { + throw new BadRequestException( + "errors.discounts.you-should-provide-at-least-one-menu", + ); + } else if (payload.menus.some((menu) => menu.restaurantIds.length === 0)) { + throw new BadRequestException( + "errors.discounts.all-menus-should-have-at-least-one-restaurant", + ); + } else if (payload.menus.some((menu) => menu.categoryIds.length === 0)) { + throw new BadRequestException( + "errors.discounts.all-menus-should-have-at-least-one-category", + ); + } + + // If worker is owner, check if they own all provided restaurant ids + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + } else if (worker.role === "OWNER" || worker.role === "ADMIN") { + const restaurantIdsSet = new Set( + worker.role === "OWNER" + ? worker.ownedRestaurants.map((r) => r.id) + : worker.workersToRestaurants.map((r) => r.restaurantId), + ); + + const menusRestaurantIds = payload.menus.flatMap( + (menu) => menu.restaurantIds, + ); + + if (menusRestaurantIds.some((id) => !restaurantIdsSet.has(id))) { + throw new BadRequestException( + "errors.discounts.you-provided-restaurant-id-that-you-dont-own", + ); + } + } + } + + public async create( + payload: CreateDiscountDto, + options: { worker: RequestWorker }, + ) { + const { worker } = options; + + await this.validatePayload(payload, worker); + + const discount = await this.pg.transaction(async (tx) => { + const [discount] = await tx + .insert(discounts) + .values({ + ...payload, + activeFrom: new Date(payload.activeFrom), + activeTo: new Date(payload.activeTo), + }) + .returning({ + id: discounts.id, + }); + + const connections = payload.menus.flatMap( + ({ dishesMenuId, categoryIds, restaurantIds }) => { + return restaurantIds.flatMap((restaurantId) => + categoryIds.map( + (categoryId) => + ({ + discountId: discount.id, + dishesMenuId, + restaurantId, + dishCategoryId: categoryId, + }) as typeof discountsConnections.$inferInsert, + ), + ); + }, + ); + + // Insert connections + await tx + .insert(discountsConnections) + .values(connections) + .onConflictDoNothing(); + + if (payload.guestIds && payload.guestIds.length > 0) { + await tx.insert(discountsToGuests).values( + payload.guestIds.map((guestId) => ({ + discountId: discount.id, + guestId, + })), + ); + } + + return discount; + }); + + return await this.findOne(discount.id, { worker }); + } + + public async update( + id: string, + payload: UpdateDiscountDto, + options: { worker: RequestWorker }, + ) { + const { worker } = options; + + const existingDiscount = await this.findOne(id, { worker }); + if (!existingDiscount) { + throw new BadRequestException( + "errors.discounts.discount-with-provided-id-not-found", + ); + } + + if (payload.menus) { + await this.validatePayload(payload, worker); + } + + const updatedDiscount = await this.pg.transaction(async (tx) => { + // Update discount + const [discount] = await tx + .update(discounts) + .set({ + ...payload, + ...(payload.activeFrom + ? { activeFrom: new Date(payload.activeFrom) } + : {}), + ...(payload.activeTo ? { activeTo: new Date(payload.activeTo) } : {}), + updatedAt: new Date(), + }) + .where(eq(discounts.id, id)) + .returning({ + id: discounts.id, + }); + + if (payload.menus) { + const connections = payload.menus.flatMap( + ({ dishesMenuId, categoryIds, restaurantIds }) => { + return restaurantIds.flatMap((restaurantId) => + categoryIds.map( + (categoryId) => + ({ + discountId: discount.id, + dishesMenuId, + restaurantId, + dishCategoryId: categoryId, + }) as typeof discountsConnections.$inferInsert, + ), + ); + }, + ); + + await tx + .delete(discountsConnections) + .where(eq(discountsConnections.discountId, id)); + + if (connections.length > 0) { + // Insert connections + await tx + .insert(discountsConnections) + .values(connections) + .onConflictDoNothing(); + } + + await tx + .delete(discountsToGuests) + .where(eq(discountsToGuests.discountId, id)); + + if (payload.guestIds && payload.guestIds.length > 0) { + await tx.insert(discountsToGuests).values( + payload.guestIds.map((guestId) => ({ + discountId: discount.id, + guestId, + })), + ); + } + } + + return discount; + }); + + return await this.findOne(updatedDiscount.id, { worker }); + } +} diff --git a/src/discounts/services/order-discounts.service.ts b/src/discounts/services/order-discounts.service.ts new file mode 100644 index 0000000..5f56840 --- /dev/null +++ b/src/discounts/services/order-discounts.service.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; + +@Injectable() +export class OrderDiscountsService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + public async getOrderDiscounts(orderId: string) { + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, orderId), + columns: { + type: true, + from: true, + guestId: true, + restaurantId: true, + }, + }); + + if (!order) { + return []; + } + + const discounts = await this.pg.query.discounts.findMany({ + where: (discounts, { or, eq, exists, ilike, and, inArray }) => + or( + and( + // Order from in array // + ilike(discounts.orderFroms, order.from), + ), + ), + }); + } +} diff --git a/src/dish-categories/dish-categories.controller.ts b/src/dish-categories/dish-categories.controller.ts new file mode 100644 index 0000000..4744887 --- /dev/null +++ b/src/dish-categories/dish-categories.controller.ts @@ -0,0 +1,157 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { FilterParams, IFilters } from "@core/decorators/filter.decorator"; +import { + IPagination, + PaginationParams, +} from "@core/decorators/pagination.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { ISorting, SortingParams } from "@core/decorators/sorting.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { Body, Get, Param, Post, Put } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; + +import { DishCategoriesService } from "./dish-categories.service"; +import { CreateDishCategoryDto } from "./dtos/create-dish-category.dto"; +import { UpdateDishCategoryDto } from "./dtos/update-dish-category.dto"; +import { DishCategoriesPaginatedDto } from "./entities/dish-categories-paginated.entity"; +import { DishCategoryEntity } from "./entities/dish-category.entity"; + +@Controller("dish-categories") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class DishCategoriesController { + constructor(private readonly dishCategoriesService: DishCategoriesService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @ApiOperation({ + summary: "Gets dish categories that are available in system", + }) + @Serializable(DishCategoriesPaginatedDto) + @ApiOkResponse({ + description: "Dish categories have been successfully fetched", + type: DishCategoriesPaginatedDto, + }) + async findMany( + @SortingParams({ + fields: [ + "id", + "name", + "sortIndex", + "showForWorkers", + "showForGuests", + "updatedAt", + "createdAt", + ], + }) + sorting: ISorting, + @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, + ): Promise { + const total = await this.dishCategoriesService.getTotalCount(filters); + const data = await this.dishCategoriesService.findMany({ + pagination, + sorting, + filters, + }); + + return { + data, + meta: { + ...pagination, + total, + }, + }; + } + + @EnableAuditLog() + @Post() + @Serializable(DishCategoryEntity) + @ApiOperation({ summary: "Creates a new dish category" }) + @ApiCreatedResponse({ + description: "Dish category has been successfully created", + }) + async create( + @Body() data: CreateDishCategoryDto, + ): Promise { + const category = await this.dishCategoriesService.create(data); + + if (!category) { + throw new BadRequestException("Failed to create dish category"); + } + + return category; + } + + @EnableAuditLog({ onlyErrors: true }) + @Get(":id") + @Serializable(DishCategoryEntity) + @ApiOperation({ summary: "Gets a dish category by id" }) + @ApiOkResponse({ + description: "Dish category has been successfully fetched", + type: DishCategoryEntity, + }) + @ApiNotFoundResponse({ + description: "Dish category with this id doesn't exist", + }) + @ApiBadRequestResponse({ + description: "Id must be a string and provided", + }) + async findOne(@Param("id") id?: string): Promise { + if (!id) { + throw new BadRequestException("Id must be a string and provided"); + } + + const category = await this.dishCategoriesService.findById(id); + + if (!category) { + throw new NotFoundException("Dish category with this id doesn't exist"); + } + + return category; + } + + @EnableAuditLog() + @Put(":id") + @Serializable(DishCategoryEntity) + @ApiOperation({ summary: "Updates a dish category by id" }) + @ApiOkResponse({ + description: "Dish category has been successfully updated", + type: DishCategoryEntity, + }) + @ApiNotFoundResponse({ + description: "Dish category with this id doesn't exist", + }) + @ApiBadRequestResponse({ + description: "Id must be a string and provided", + }) + async update( + @Param("id") id: string, + @Body() data: UpdateDishCategoryDto, + ): Promise { + if (!id) { + throw new BadRequestException("Id must be a string and provided"); + } + + const updatedCategory = await this.dishCategoriesService.update(id, { + ...data, + updatedAt: new Date(), + }); + + if (!updatedCategory) { + throw new NotFoundException("Dish category with this id doesn't exist"); + } + + return updatedCategory; + } +} diff --git a/src/dish-categories/dish-categories.module.ts b/src/dish-categories/dish-categories.module.ts new file mode 100644 index 0000000..c7ecf43 --- /dev/null +++ b/src/dish-categories/dish-categories.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; + +import { DishCategoriesController } from "./dish-categories.controller"; +import { DishCategoriesService } from "./dish-categories.service"; + +@Module({ + imports: [DrizzleModule], + controllers: [DishCategoriesController], + providers: [DishCategoriesService], + exports: [DishCategoriesService], +}) +export class DishCategoriesModule {} diff --git a/src/dish-categories/dish-categories.service.ts b/src/dish-categories/dish-categories.service.ts new file mode 100644 index 0000000..6ca1d4b --- /dev/null +++ b/src/dish-categories/dish-categories.service.ts @@ -0,0 +1,137 @@ +import { IFilters } from "@core/decorators/filter.decorator"; +import { + IPagination, + PAGINATION_DEFAULT_LIMIT, +} from "@core/decorators/pagination.decorator"; +import { ISorting } from "@core/decorators/sorting.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { DrizzleUtils } from "@postgress-db/drizzle-utils"; +import { schema } from "@postgress-db/drizzle.module"; +import { and, asc, count, desc, eq, sql, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; + +import { CreateDishCategoryDto } from "./dtos/create-dish-category.dto"; +import { UpdateDishCategoryDto } from "./dtos/update-dish-category.dto"; +import { DishCategoryEntity } from "./entities/dish-category.entity"; + +@Injectable() +export class DishCategoriesService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + public async getTotalCount(filters?: IFilters): Promise { + const query = this.pg + .select({ + value: count(), + }) + .from(schema.dishCategories); + + if (filters) { + query.where( + DrizzleUtils.buildFilterConditions(schema.dishCategories, filters), + ); + } + + return await query.then((res) => res[0].value); + } + + public async findMany(options?: { + pagination?: IPagination; + sorting?: ISorting; + filters?: IFilters; + }): Promise { + const { pagination, sorting, filters } = options ?? {}; + + const conditions: SQL[] = []; + + if (filters) { + conditions.push( + DrizzleUtils.buildFilterConditions( + schema.dishCategories, + filters, + ) as SQL, + ); + } + + const fetchedCategories = await this.pg.query.dishCategories.findMany({ + ...(conditions.length > 0 && { where: and(...conditions) }), + limit: pagination?.size ?? PAGINATION_DEFAULT_LIMIT, + offset: pagination?.offset ?? 0, + orderBy: sorting?.sortBy + ? [ + sorting.sortOrder === "asc" + ? asc(sql.identifier(sorting.sortBy)) + : desc(sql.identifier(sorting.sortBy)), + ] + : undefined, + }); + + return fetchedCategories; + } + + public async create( + dto: CreateDishCategoryDto, + ): Promise { + const startIndex = 10; + const sortIndex = await this.pg + .select({ + value: count(), + }) + .from(schema.dishCategories); + + const categories = await this.pg + .insert(schema.dishCategories) + .values({ + ...dto, + sortIndex: sortIndex[0].value + startIndex, + }) + .returning(); + + const category = categories[0]; + if (!category) { + throw new ServerErrorException( + "errors.dish-categories.failed-to-create-dish-category", + ); + } + + return category; + } + + public async update( + id: string, + dto: UpdateDishCategoryDto, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException( + "errors.common.atleast-one-field-should-be-provided", + ); + } + + await this.pg + .update(schema.dishCategories) + .set(dto) + .where(eq(schema.dishCategories.id, id)); + + const result = await this.pg + .select() + .from(schema.dishCategories) + .where(eq(schema.dishCategories.id, id)) + .limit(1); + + return result[0]; + } + + public async findById(id: string): Promise { + const result = await this.pg + .select() + .from(schema.dishCategories) + .where(eq(schema.dishCategories.id, id)) + .limit(1); + + return result[0]; + } +} diff --git a/src/dish-categories/dtos/create-dish-category.dto.ts b/src/dish-categories/dtos/create-dish-category.dto.ts new file mode 100644 index 0000000..c8f8b9b --- /dev/null +++ b/src/dish-categories/dtos/create-dish-category.dto.ts @@ -0,0 +1,14 @@ +import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; + +import { DishCategoryEntity } from "../entities/dish-category.entity"; + +export class CreateDishCategoryDto extends IntersectionType( + PickType(DishCategoryEntity, ["name", "menuId"]), + PartialType( + PickType(DishCategoryEntity, [ + "showForWorkers", + "showForGuests", + "sortIndex", + ]), + ), +) {} diff --git a/src/dish-categories/dtos/update-dish-category.dto.ts b/src/dish-categories/dtos/update-dish-category.dto.ts new file mode 100644 index 0000000..5bcaecf --- /dev/null +++ b/src/dish-categories/dtos/update-dish-category.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateDishCategoryDto } from "./create-dish-category.dto"; + +export class UpdateDishCategoryDto extends PartialType(CreateDishCategoryDto) { + updatedAt?: Date; +} diff --git a/src/dish-categories/entities/dish-categories-paginated.entity.ts b/src/dish-categories/entities/dish-categories-paginated.entity.ts new file mode 100644 index 0000000..5630638 --- /dev/null +++ b/src/dish-categories/entities/dish-categories-paginated.entity.ts @@ -0,0 +1,15 @@ +import { PaginationResponseDto } from "@core/dto/pagination-response.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; + +import { DishCategoryEntity } from "./dish-category.entity"; + +export class DishCategoriesPaginatedDto extends PaginationResponseDto { + @Expose() + @ApiProperty({ + description: "Array of dish categories", + type: [DishCategoryEntity], + }) + @Type(() => DishCategoryEntity) + data: DishCategoryEntity[]; +} diff --git a/src/dish-categories/entities/dish-category.entity.ts b/src/dish-categories/entities/dish-category.entity.ts new file mode 100644 index 0000000..f77a4af --- /dev/null +++ b/src/dish-categories/entities/dish-category.entity.ts @@ -0,0 +1,79 @@ +import { + IsBoolean, + IsISO8601, + IsNumber, + IsString, + IsUUID, +} from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IDishCategory } from "@postgress-db/schema/dish-categories"; +import { Expose } from "class-transformer"; + +export class DishCategoryEntity implements IDishCategory { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the dish category", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + menuId: string; + + @Expose() + @IsString() + @ApiProperty({ + description: "Name of the category", + example: "Main Course", + }) + name: string; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Whether the category is visible for workers", + example: true, + }) + showForWorkers: boolean; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: + "Whether the category is visible for guests at site and in app", + example: true, + }) + showForGuests: boolean; + + @Expose() + @IsNumber() + @ApiProperty({ + description: "Sorting index in the admin menu", + example: 1, + }) + sortIndex: number; + + @Expose() + @IsISO8601() + @ApiProperty({ + description: "Date when category was created", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + createdAt: Date; + + @Expose() + @IsISO8601() + @ApiProperty({ + description: "Date when category was last updated", + example: new Date("2022-03-01T05:20:52.000Z"), + type: Date, + }) + updatedAt: Date; +} diff --git a/src/dishes-menus/dishes-menus.controller.ts b/src/dishes-menus/dishes-menus.controller.ts new file mode 100644 index 0000000..6e3913f --- /dev/null +++ b/src/dishes-menus/dishes-menus.controller.ts @@ -0,0 +1,81 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { Body, Delete, Get, Param, Patch, Post } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { DishesMenusService } from "src/dishes-menus/dishes-menus.service"; +import { CreateDishesMenuDto } from "src/dishes-menus/dto/create-dishes-menu.dto"; +import { UpdateDishesMenuDto } from "src/dishes-menus/dto/update-dishes-menu.dto"; +import { DishesMenuEntity } from "src/dishes-menus/entity/dishes-menu.entity"; + +@Controller("dishes-menus") +export class DishesMenusController { + constructor(private readonly dishesMenusService: DishesMenusService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(DishesMenuEntity) + @ApiOperation({ + summary: "Gets all dish menus", + }) + @ApiOkResponse({ + description: "Dish menus have been successfully fetched", + type: [DishesMenuEntity], + }) + async findAll(@Worker() worker: RequestWorker) { + return this.dishesMenusService.findMany({ + worker, + }); + } + + @EnableAuditLog() + @Post() + @Serializable(DishesMenuEntity) + @ApiOperation({ + summary: "Creates a new dish menu", + }) + @ApiOkResponse({ + description: "Dish menu has been successfully created", + }) + async create( + @Worker() worker: RequestWorker, + @Body() payload: CreateDishesMenuDto, + ) { + return this.dishesMenusService.create(payload, { + worker, + }); + } + + @EnableAuditLog() + @Patch(":dishesMenuId") + @Serializable(DishesMenuEntity) + @ApiOperation({ + summary: "Updates a dish menu", + }) + @ApiOkResponse({ + description: "Dish menu has been successfully updated", + }) + async update( + @Worker() worker: RequestWorker, + @Param("dishesMenuId") dishesMenuId: string, + @Body() payload: UpdateDishesMenuDto, + ) { + return this.dishesMenusService.update(dishesMenuId, payload, { + worker, + }); + } + + @EnableAuditLog() + @Delete(":dishesMenuId") + @ApiOperation({ + summary: "Removes a dish menu", + }) + async remove( + @Worker() worker: RequestWorker, + @Param("dishesMenuId") dishesMenuId: string, + ) { + return this.dishesMenusService.remove(dishesMenuId, { worker }); + } +} diff --git a/src/dishes-menus/dishes-menus.module.ts b/src/dishes-menus/dishes-menus.module.ts new file mode 100644 index 0000000..713022b --- /dev/null +++ b/src/dishes-menus/dishes-menus.module.ts @@ -0,0 +1,23 @@ +import { BullModule } from "@nestjs/bullmq"; +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { RedlockModule } from "src/@base/redlock/redlock.module"; +import { DISHES_MENUS_QUEUE } from "src/dishes-menus"; +import { DishesMenusController } from "src/dishes-menus/dishes-menus.controller"; +import { DishesMenusProcessor } from "src/dishes-menus/dishes-menus.processor"; +import { DishesMenusProducer } from "src/dishes-menus/dishes-menus.producer"; +import { DishesMenusService } from "src/dishes-menus/dishes-menus.service"; + +@Module({ + imports: [ + DrizzleModule, + RedlockModule, + BullModule.registerQueue({ + name: DISHES_MENUS_QUEUE, + }), + ], + controllers: [DishesMenusController], + providers: [DishesMenusService, DishesMenusProducer, DishesMenusProcessor], + exports: [DishesMenusService], +}) +export class DishesMenusModule {} diff --git a/src/dishes-menus/dishes-menus.processor.ts b/src/dishes-menus/dishes-menus.processor.ts new file mode 100644 index 0000000..0bdad29 --- /dev/null +++ b/src/dishes-menus/dishes-menus.processor.ts @@ -0,0 +1,85 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Inject, Logger } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; +import { Job } from "bullmq"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { RedlockService } from "src/@base/redlock/redlock.service"; +import { PG_CONNECTION } from "src/constants"; +import { DISHES_MENUS_QUEUE, DishesMenusQueueJobName } from "src/dishes-menus"; + +@Processor(DISHES_MENUS_QUEUE, {}) +export class DishesMenusProcessor extends WorkerHost { + private readonly logger = new Logger(DishesMenusProcessor.name); + + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly redlockService: RedlockService, + ) { + super(); + } + + async process(job: Job) { + const { name } = job; + + try { + switch (name) { + case DishesMenusQueueJobName.CREATE_OWNERS_DEFAULT_MENUS: { + const lock = await this.redlockService.acquire( + ["locks:create-owners-default-menus"], + 10_000, + ); + + try { + await this.createOwnersDefaultMenus(); + } finally { + await this.redlockService.release(lock); + } + break; + } + + default: { + throw new Error(`Unknown job name`); + } + } + } catch (error) { + this.logger.error(`Failed to process ${name} job`, error); + + throw error; + } + } + + private async createOwnersDefaultMenus() { + const ownersWithoutMenus = await this.pg.query.workers.findMany({ + where: (workers, { and, eq, notExists }) => + and( + eq(workers.role, "OWNER"), + notExists( + this.pg + .select({ + id: dishesMenus.id, + }) + .from(dishesMenus) + .where( + and( + eq(dishesMenus.ownerId, workers.id), + eq(dishesMenus.isRemoved, false), + ), + ), + ), + ), + columns: { + id: true, + }, + }); + + for (const owner of ownersWithoutMenus) { + await this.pg.insert(dishesMenus).values({ + ownerId: owner.id, + name: "Default", + isRemoved: false, + }); + } + } +} diff --git a/src/dishes-menus/dishes-menus.producer.ts b/src/dishes-menus/dishes-menus.producer.ts new file mode 100644 index 0000000..b4d82c3 --- /dev/null +++ b/src/dishes-menus/dishes-menus.producer.ts @@ -0,0 +1,38 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable, Logger } from "@nestjs/common"; +import { JobsOptions, Queue } from "bullmq"; +import { DISHES_MENUS_QUEUE, DishesMenusQueueJobName } from "src/dishes-menus"; + +@Injectable() +export class DishesMenusProducer { + private readonly logger = new Logger(DishesMenusProducer.name); + + constructor( + @InjectQueue(DISHES_MENUS_QUEUE) + private readonly queue: Queue, + ) {} + + private async addJob( + name: DishesMenusQueueJobName, + data: any, + opts?: JobsOptions, + ) { + try { + return await this.queue.add(name, data, opts); + } catch (error) { + this.logger.error(`Failed to add ${name} job to queue:`, error); + throw error; + } + } + + public async createOwnersDefaultMenu() { + try { + await this.addJob( + DishesMenusQueueJobName.CREATE_OWNERS_DEFAULT_MENUS, + {}, + ); + } catch (error) { + this.logger.error(error); + } + } +} diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts new file mode 100644 index 0000000..c465489 --- /dev/null +++ b/src/dishes-menus/dishes-menus.service.ts @@ -0,0 +1,441 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { + dishesMenus, + dishesMenusToRestaurants, +} from "@postgress-db/schema/dishes-menus"; +import { and, desc, eq, inArray, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { DishesMenusProducer } from "src/dishes-menus/dishes-menus.producer"; +import { CreateDishesMenuDto } from "src/dishes-menus/dto/create-dishes-menu.dto"; +import { UpdateDishesMenuDto } from "src/dishes-menus/dto/update-dishes-menu.dto"; +import { DishesMenuEntity } from "src/dishes-menus/entity/dishes-menu.entity"; + +@Injectable() +export class DishesMenusService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly dishesMenusProducer: DishesMenusProducer, + ) {} + + private async onApplicationBootstrap() { + // TODO: replace to CRON + await this.dishesMenusProducer.createOwnersDefaultMenu(); + } + + /** + * Fetches a dish menu by its ID + * @param id - The ID of the dish menu + * @param options - Options for fetching the menu + * @param options.worker - The worker making the request + * @returns The fetched dish menu + */ + public async findOne( + id: string, + options: { + worker: RequestWorker; + }, + ): Promise { + const { worker } = options; + + const fetchedMenu = await this.pg.query.dishesMenus.findFirst({ + where: and( + eq(dishesMenus.id, id), + // For owner only their own menus + worker.role === "OWNER" + ? eq(dishesMenus.ownerId, worker.id) + : undefined, + // For admin only menus of their restaurants + worker.role === "ADMIN" + ? inArray( + dishesMenus.ownerId, + worker.workersToRestaurants.map( + ({ restaurantId }) => restaurantId, + ), + ) + : undefined, + ), + with: { + dishesMenusToRestaurants: { + columns: {}, + with: { + restaurant: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + owner: { + columns: { + id: true, + name: true, + }, + }, + }, + }); + + if (!fetchedMenu) { + throw new NotFoundException("errors.dishes-menus.dish-menu-not-found"); + } + + return { + ...fetchedMenu, + restaurants: fetchedMenu.dishesMenusToRestaurants.map( + ({ restaurant }) => restaurant, + ), + }; + } + /** + * Fetches all dish menus + * @param options - Options for fetching the menus + * @param options.worker - The worker making the request + * @returns All dish menus + */ + public async findMany(options: { + worker: RequestWorker; + }): Promise { + const { worker } = options; + + const fetchedMenus = await this.pg.query.dishesMenus.findMany({ + // ...(conditions.length > 0 && { where: and(...conditions) }), + where: (dishesMenus, { and, exists, eq, inArray }) => { + const conditions: SQL[] = [ + // Exclude removed menus + eq(dishesMenus.isRemoved, false), + ]; + + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + // Fetch all menus + } else if (worker.role === "OWNER") { + // Fetch all menus of the owner + conditions.push(eq(dishesMenus.ownerId, worker.id)); + } else if (worker.role === "ADMIN") { + // Fetch all menus of the restaurants of the admin + conditions.push( + exists( + this.pg + .select({ restaurantId: dishesMenusToRestaurants.restaurantId }) + .from(dishesMenusToRestaurants) + .where( + and( + eq(dishesMenusToRestaurants.dishesMenuId, dishesMenus.id), + inArray( + dishesMenusToRestaurants.restaurantId, + worker.workersToRestaurants.map( + ({ restaurantId }) => restaurantId, + ), + ), + ), + ), + ), + ); + } + + return and(...conditions); + }, + with: { + dishesMenusToRestaurants: { + columns: {}, + with: { + restaurant: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + owner: { + columns: { + id: true, + name: true, + }, + }, + }, + orderBy: [desc(dishesMenus.createdAt)], + }); + + return fetchedMenus.map(({ dishesMenusToRestaurants, ...dishesMenu }) => ({ + ...dishesMenu, + restaurants: dishesMenusToRestaurants.map(({ restaurant }) => restaurant), + })); + } + + private async validateRestaurants(ownerId: string, restaurantIds: string[]) { + if (restaurantIds.length === 0) { + return true; + } + + const restaurants = await this.pg.query.restaurants.findMany({ + where: (restaurants, { inArray, and, eq }) => + and( + inArray(restaurants.id, restaurantIds), + eq(restaurants.ownerId, ownerId), + ), + columns: { + id: true, + }, + }); + + if (restaurants.length !== restaurantIds.length) { + throw new BadRequestException( + "errors.dishes-menus.some-restaurant-are-not-owned-by-the-owner", + { + property: "restaurantIds", + }, + ); + } + } + + /** + * Creates a new dish menu + * @param options - Options for creating the menu + * @param options.worker - The worker making the request + * @returns The created dish menu + */ + public async create( + payload: CreateDishesMenuDto, + options: { + worker: RequestWorker; + }, + ): Promise { + const { worker } = options; + + const { restaurantIds, ...menuData } = payload; + + let ownerId: string | null = null; + + if (worker.role === "OWNER") { + // For owner we will auto-assign it's id to menu + ownerId = worker.id; + } else if ( + worker.role === "SYSTEM_ADMIN" || + worker.role === "CHIEF_ADMIN" + ) { + // SYSTEM and CHIEF admins should provide ownerId + if (!payload.ownerId) { + throw new BadRequestException( + "errors.dishes-menus.owner-id-is-required", + { + property: "ownerId", + }, + ); + } + + ownerId = payload.ownerId; + } else { + throw new ForbiddenException("errors.dishes-menus.not-enough-rights"); + } + + if (typeof ownerId !== "string") { + throw new ServerErrorException(); + } + + // Validate restaurants + await this.validateRestaurants(ownerId, restaurantIds); + + const menuId = await this.pg.transaction(async (tx) => { + const [createdMenu] = await tx + .insert(dishesMenus) + .values({ + ...menuData, + ownerId: String(ownerId), + }) + .returning({ + id: dishesMenus.id, + }); + + if (restaurantIds.length > 0) { + await tx.insert(dishesMenusToRestaurants).values( + restaurantIds.map((restaurantId) => ({ + dishesMenuId: createdMenu.id, + restaurantId, + })), + ); + } + + return createdMenu.id; + }); + + return this.findOne(menuId, { + worker, + }); + } + + /** + * Updates a dish menu + * @param id - The ID of the dish menu + * @param payload - The payload for updating the menu + * @param options - Options for updating the menu + * @param options.worker - The worker making the request + * @returns The updated dish menu + */ + public async update( + id: string, + payload: UpdateDishesMenuDto, + options: { + worker: RequestWorker; + }, + ) { + const { worker } = options; + + const { restaurantIds, ...menuData } = payload; + + const menu = await this.pg.query.dishesMenus.findFirst({ + where: eq(dishesMenus.id, id), + columns: { + ownerId: true, + }, + with: { + dishesMenusToRestaurants: { + columns: { + restaurantId: true, + }, + }, + }, + }); + + if (!menu) { + throw new NotFoundException("errors.dishes-menus.dish-menu-not-found"); + } + + if (worker.role === "OWNER" && menu.ownerId !== worker.id) { + // Owners can only update their own menus + throw new ForbiddenException("errors.dishes-menus.not-enough-rights"); + } else if ( + // Admins can only update menus that have their restaurants + worker.role === "ADMIN" && + !worker.workersToRestaurants.some( + ({ restaurantId }) => restaurantId === menu.ownerId, + ) + ) { + throw new ForbiddenException("errors.dishes-menus.not-enough-rights"); + } else if ( + worker.role !== "SYSTEM_ADMIN" && + worker.role !== "CHIEF_ADMIN" && + worker.role !== "OWNER" && + worker.role !== "ADMIN" + ) { + // If not a OWNER, or ADMIN, or SYSTEM_ADMIN, or CHIEF_ADMIN, then you can't update the menu + throw new ForbiddenException("errors.dishes-menus.not-enough-rights"); + } + + const ownerId = + (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") && + payload.ownerId + ? payload.ownerId + : menu.ownerId; + + if (restaurantIds) { + // Validate restaurants + await this.validateRestaurants(ownerId, restaurantIds); + } + + // Make update operation + await this.pg.transaction(async (tx) => { + await tx + .update(dishesMenus) + .set({ + ...menuData, + ownerId: String(ownerId), + }) + .where(eq(dishesMenus.id, id)); + + // TODO: replace with individual function that will check if menu have some dishes that was assigned to editable restaurants + if (restaurantIds) { + await tx + .delete(dishesMenusToRestaurants) + .where(eq(dishesMenusToRestaurants.dishesMenuId, id)); + + if (restaurantIds.length > 0) { + await tx.insert(dishesMenusToRestaurants).values( + restaurantIds.map((restaurantId) => ({ + dishesMenuId: id, + restaurantId, + })), + ); + } + } + }); + + return this.findOne(id, { + worker, + }); + } + + /** + * Removes a dish menu + * @param id - The ID of the dish menu + * @param options - Options for removing the menu + * @param options.worker - The worker making the request + */ + public async remove( + id: string, + options: { + worker: RequestWorker; + }, + ) { + const { worker } = options; + + if ( + worker.role !== "SYSTEM_ADMIN" && + worker.role !== "CHIEF_ADMIN" && + worker.role !== "OWNER" + ) { + throw new ForbiddenException("errors.dishes-menus.not-enough-rights"); + } + + const menu = await this.pg.query.dishesMenus.findFirst({ + where: eq(dishesMenus.id, id), + columns: {}, + with: { + dishesMenusToRestaurants: { + columns: { + restaurantId: true, + }, + }, + }, + }); + + if (!menu) { + throw new NotFoundException("errors.dishes-menus.dish-menu-not-found"); + } + + if (menu.dishesMenusToRestaurants.length > 0) { + throw new BadRequestException("errors.dishes-menus.menu-has-restaurants"); + } + + const [removedMenu] = await this.pg + .update(dishesMenus) + .set({ + isRemoved: true, + removedAt: new Date(), + }) + .where( + and( + eq(dishesMenus.id, id), + eq(dishesMenus.isRemoved, false), + worker.role === "OWNER" + ? eq(dishesMenus.ownerId, worker.id) + : undefined, + ), + ) + .returning({ + id: dishesMenus.id, + }); + + if (!removedMenu) { + throw new NotFoundException("errors.dishes-menus.unable-to-remove-menu"); + } + + return true; + } +} diff --git a/src/dishes-menus/dto/create-dishes-menu.dto.ts b/src/dishes-menus/dto/create-dishes-menu.dto.ts new file mode 100644 index 0000000..8f9e314 --- /dev/null +++ b/src/dishes-menus/dto/create-dishes-menu.dto.ts @@ -0,0 +1,23 @@ +import { IsArray, IsUUID } from "@i18n-class-validator"; +import { + ApiProperty, + IntersectionType, + PartialType, + PickType, +} from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { DishesMenuEntity } from "src/dishes-menus/entity/dishes-menu.entity"; + +export class CreateDishesMenuDto extends IntersectionType( + PickType(DishesMenuEntity, ["name"]), + PartialType(PickType(DishesMenuEntity, ["ownerId"])), +) { + @Expose() + @IsArray() + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: "Restaurants that have this menu", + example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + }) + restaurantIds: string[]; +} diff --git a/src/dishes-menus/dto/update-dishes-menu.dto.ts b/src/dishes-menus/dto/update-dishes-menu.dto.ts new file mode 100644 index 0000000..30e9dd2 --- /dev/null +++ b/src/dishes-menus/dto/update-dishes-menu.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateDishesMenuDto } from "src/dishes-menus/dto/create-dishes-menu.dto"; + +export class UpdateDishesMenuDto extends PartialType(CreateDishesMenuDto) {} diff --git a/src/dishes-menus/entity/dishes-menu.entity.ts b/src/dishes-menus/entity/dishes-menu.entity.ts new file mode 100644 index 0000000..6f6b4ae --- /dev/null +++ b/src/dishes-menus/entity/dishes-menu.entity.ts @@ -0,0 +1,104 @@ +import { + IsArray, + IsBoolean, + IsISO8601, + IsObject, + IsString, + IsUUID, + MinLength, +} from "@i18n-class-validator"; +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { IDishesMenu } from "@postgress-db/schema/dishes-menus"; +import { Expose, Type } from "class-transformer"; +import { RestaurantEntity } from "src/restaurants/@/entities/restaurant.entity"; +import { WorkerEntity } from "src/workers/entities/worker.entity"; + +export class DishesMenuRestaurantEntity extends PickType(RestaurantEntity, [ + "id", + "name", +]) {} + +export class DishesMenuOwnerEntity extends PickType(WorkerEntity, [ + "id", + "name", +]) {} + +export class DishesMenuEntity implements IDishesMenu { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsString() + @MinLength(3) + @Expose() + @ApiProperty({ + description: "Name of the menu", + example: "Lunch Menu", + }) + name: string; + + @Expose() + @IsArray() + @Type(() => DishesMenuRestaurantEntity) + @ApiProperty({ + description: "Restaurants that have this menu", + type: [DishesMenuRestaurantEntity], + }) + restaurants: DishesMenuRestaurantEntity[]; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Owner identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + ownerId: string; + + @Expose() + @IsObject() + @Type(() => DishesMenuOwnerEntity) + @ApiProperty({ + description: "Owner of the menu", + type: DishesMenuOwnerEntity, + }) + owner: DishesMenuOwnerEntity; + + @IsBoolean() + // @Expose() + @ApiProperty({ + description: "Whether the menu is removed", + example: false, + }) + isRemoved: boolean; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when menu was created", + example: new Date("2024-01-01T00:00:00.000Z"), + type: Date, + }) + createdAt: Date; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when menu was last updated", + example: new Date("2024-01-01T00:00:00.000Z"), + type: Date, + }) + updatedAt: Date; + + @IsISO8601() + // @Expose() + @ApiProperty({ + description: "Date when menu was removed", + example: new Date("2024-01-01T00:00:00.000Z"), + type: Date, + }) + removedAt: Date | null; +} diff --git a/src/dishes-menus/index.ts b/src/dishes-menus/index.ts new file mode 100644 index 0000000..2bfc25b --- /dev/null +++ b/src/dishes-menus/index.ts @@ -0,0 +1,5 @@ +export const DISHES_MENUS_QUEUE = "dishes-menus"; + +export enum DishesMenusQueueJobName { + CREATE_OWNERS_DEFAULT_MENUS = "create-owners-default-menus", +} diff --git a/src/dishes/@/dishes.controller.ts b/src/dishes/@/dishes.controller.ts new file mode 100644 index 0000000..5e2e85e --- /dev/null +++ b/src/dishes/@/dishes.controller.ts @@ -0,0 +1,247 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { + FilterCondition, + FilterParams, + IFilters, +} from "@core/decorators/filter.decorator"; +import { + IPagination, + PaginationParams, +} from "@core/decorators/pagination.decorator"; +import SearchQuery from "@core/decorators/search.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { ISorting, SortingParams } from "@core/decorators/sorting.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { StringValuePipe } from "@core/pipes/string.pipe"; +import { Body, Get, Param, Post, Put, Query } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; + +import { DishesService } from "./dishes.service"; +import { CreateDishDto } from "./dtos/create-dish.dto"; +import { UpdateDishDto } from "./dtos/update-dish.dto"; +import { DishEntity } from "./entities/dish.entity"; +import { DishesPaginatedDto } from "./entities/dishes-paginated.entity"; + +@Controller("dishes") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class DishesController { + constructor(private readonly dishesService: DishesService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @ApiOperation({ + summary: "Gets dishes that are available in system", + }) + @Serializable(DishesPaginatedDto) + @ApiOkResponse({ + description: "Dishes have been successfully fetched", + type: DishesPaginatedDto, + }) + @ApiQuery({ + name: "menuId", + description: "Filter out dishes by menu id", + type: String, + required: false, + }) + @ApiQuery({ + name: "categoryId", + description: "Filter out dishes by category id", + type: String, + required: false, + }) + async findMany( + @SortingParams({ + fields: [ + "id", + "name", + "cookingTimeInMin", + "weight", + "updatedAt", + "createdAt", + ], + }) + sorting: ISorting, + @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, + @SearchQuery() search?: string, + @Query("menuId", new StringValuePipe()) menuId?: string | null, + @Query("categoryId", new StringValuePipe()) categoryId?: string | null, + ): Promise { + if (typeof search === "string" && search.length > 0 && search !== "null") { + if (!filters) { + filters = { filters: [] }; + } + + filters.filters.push({ + field: "name", + value: search, + condition: FilterCondition.Contains, + }); + } + + if (menuId) { + if (!filters) { + filters = { filters: [] }; + } + + filters.filters.push({ + field: "menuId", + value: menuId, + condition: FilterCondition.Equals, + }); + } + + const total = await this.dishesService.getTotalCount({ + filters, + categoryId, + }); + + const data = await this.dishesService.findMany({ + pagination, + sorting, + filters, + categoryId, + }); + + return { + data, + meta: { + ...pagination, + total, + }, + }; + } + + @EnableAuditLog() + @Post() + @Serializable(DishEntity) + @ApiOperation({ + summary: "Creates a new dish", + }) + @ApiCreatedResponse({ + description: "Dish has been successfully created", + type: DishEntity, + }) + @ApiBadRequestResponse({ + description: "Failed to create dish", + }) + @ApiForbiddenResponse({ + description: + "Only SYSTEM_ADMIN, CHIEF_ADMIN, OWNER, ADMIN can create dishes", + }) + async create( + @Body() data: CreateDishDto, + @Worker() worker: RequestWorker, + ): Promise { + if ( + worker.role !== "SYSTEM_ADMIN" && + worker.role !== "CHIEF_ADMIN" && + worker.role !== "OWNER" && + worker.role !== "ADMIN" + ) { + throw new ForbiddenException(); + } + + const dish = await this.dishesService.create(data, { + worker, + }); + + if (!dish) { + throw new BadRequestException("errors.dishes.failed-to-create-dish"); + } + + return dish; + } + + @EnableAuditLog({ onlyErrors: true }) + @Get(":id") + @Serializable(DishEntity) + @ApiOperation({ summary: "Gets a dish by id" }) + @ApiOkResponse({ + description: "Dish has been successfully fetched", + type: DishEntity, + }) + @ApiNotFoundResponse({ + description: "Dish with this id doesn't exist", + }) + @ApiBadRequestResponse({ + description: "Id must be a string and provided", + }) + async findOne(@Param("id") id?: string): Promise { + if (!id) { + throw new BadRequestException( + "errors.common.id-must-be-a-string-and-provided", + ); + } + + const dish = await this.dishesService.findById(id); + + if (!dish) { + throw new NotFoundException("errors.dishes.with-this-id-doesnt-exist"); + } + + return dish; + } + + @EnableAuditLog() + @Put(":id") + @Serializable(DishEntity) + @ApiOperation({ summary: "Updates a dish by id" }) + @ApiOkResponse({ + description: "Dish has been successfully updated", + type: DishEntity, + }) + @ApiNotFoundResponse({ + description: "Dish with this id doesn't exist", + }) + @ApiBadRequestResponse({ + description: "Id must be a string and provided", + }) + @ApiForbiddenResponse({ + description: + "Only SYSTEM_ADMIN, CHIEF_ADMIN, OWNER, ADMIN can update dishes", + }) + async update( + @Param("id") id: string, + @Body() data: UpdateDishDto, + @Worker() worker: RequestWorker, + ): Promise { + if (!id) { + throw new BadRequestException( + "errors.common.id-must-be-a-string-and-provided", + ); + } + + const updatedDish = await this.dishesService.update( + id, + { + ...data, + updatedAt: new Date(), + }, + { + worker, + }, + ); + + if (!updatedDish) { + throw new NotFoundException("errors.dishes.with-this-id-doesnt-exist"); + } + + return updatedDish; + } +} diff --git a/src/dishes/@/dishes.service.ts b/src/dishes/@/dishes.service.ts new file mode 100644 index 0000000..8b726da --- /dev/null +++ b/src/dishes/@/dishes.service.ts @@ -0,0 +1,375 @@ +import { IFilters } from "@core/decorators/filter.decorator"; +import { + IPagination, + PAGINATION_DEFAULT_LIMIT, +} from "@core/decorators/pagination.decorator"; +import { ISorting } from "@core/decorators/sorting.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { DrizzleUtils } from "@postgress-db/drizzle-utils"; +import { schema } from "@postgress-db/drizzle.module"; +import { dishes } from "@postgress-db/schema/dishes"; +import { and, asc, count, desc, eq, exists, sql, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; + +import { CreateDishDto } from "./dtos/create-dish.dto"; +import { UpdateDishDto } from "./dtos/update-dish.dto"; +import { DishEntity } from "./entities/dish.entity"; + +@Injectable() +export class DishesService { + constructor( + // Inject postgres connection + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + public async getTotalCount({ + filters, + categoryId, + }: { + filters?: IFilters; + categoryId?: string | null; + }): Promise { + const conditions: SQL[] = []; + + const query = this.pg + .select({ + value: count(), + }) + .from(schema.dishes); + + if (filters) { + conditions.push( + DrizzleUtils.buildFilterConditions(schema.dishes, filters) as SQL, + ); + } + + if (categoryId) { + conditions.push( + exists( + this.pg + .select({ id: schema.dishesToDishCategories.dishId }) + .from(schema.dishesToDishCategories) + .where( + and( + eq(schema.dishesToDishCategories.dishId, dishes.id), + eq(schema.dishesToDishCategories.dishCategoryId, categoryId), + ), + ), + ), + ); + } + + if (conditions.length > 0) { + query.where(and(...conditions)); + } + + return await query.then((res) => res[0].value); + } + + public async findMany(options?: { + pagination?: IPagination; + sorting?: ISorting; + filters?: IFilters; + categoryId?: string | null; + }): Promise { + const { pagination, sorting, filters, categoryId } = options ?? {}; + + const orderBy = sorting + ? [ + sorting.sortOrder === "asc" + ? asc(sql.identifier(sorting.sortBy)) + : desc(sql.identifier(sorting.sortBy)), + ] + : undefined; + + const result = await this.pg.query.dishes.findMany({ + where: (dishes, { and, eq, exists }) => { + const conditions: SQL[] = []; + + if (categoryId) { + conditions.push( + exists( + this.pg + .select({ id: schema.dishesToDishCategories.dishId }) + .from(schema.dishesToDishCategories) + .where( + and( + eq(schema.dishesToDishCategories.dishId, dishes.id), + eq( + schema.dishesToDishCategories.dishCategoryId, + categoryId, + ), + ), + ), + ), + ); + } + + if (filters) { + conditions.push( + DrizzleUtils.buildFilterConditions(schema.dishes, filters) as SQL, + ); + } + + return and(...conditions); + }, + with: { + dishesToDishCategories: { + columns: {}, + with: { + dishCategory: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + dishesToImages: { + with: { + imageFile: true, + }, + }, + }, + orderBy, + limit: pagination?.size ?? PAGINATION_DEFAULT_LIMIT, + offset: pagination?.offset ?? 0, + }); + + return result.map((dish) => ({ + ...dish, + categories: dish.dishesToDishCategories.map((dc) => dc.dishCategory), + images: dish.dishesToImages + .sort((a, b) => a.sortIndex - b.sortIndex) + .map((di) => ({ + ...di.imageFile, + alt: di.alt, + sortIndex: di.sortIndex, + })), + })); + } + + private async validateMenuId(menuId: string, worker: RequestWorker) { + // SYSTEM_ADMIN, CHIEF_ADMIN can create dishes for any menu + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + return true; + } + + const menu = await this.pg.query.dishesMenus.findFirst({ + where: eq(schema.dishesMenus.id, menuId), + columns: { + ownerId: true, + }, + with: { + dishesMenusToRestaurants: { + columns: { + restaurantId: true, + }, + }, + }, + }); + + if (!menu) { + throw new NotFoundException(); + } + + // If menu doesn't have assigned restaurants, throw error + if (menu.dishesMenusToRestaurants.length === 0) { + throw new BadRequestException( + "errors.dishes.provided-menu-doesnt-have-assigned-restaurants", + { + property: "menuId", + }, + ); + } + + // If worker is owner and menu is not owned by him, throw error + if (worker.role === "OWNER" && menu.ownerId !== worker.id) { + throw new BadRequestException( + "errors.dishes.you-dont-have-rights-to-the-provided-menu", + { + property: "menuId", + }, + ); + } + + // ADMIN can create dishes for any restaurant that is assigned to him + if (worker.role === "ADMIN") { + const adminRestaurantIdsSet = new Set( + worker.workersToRestaurants.map((wr) => wr.restaurantId), + ); + + if ( + !menu.dishesMenusToRestaurants.some((m) => + adminRestaurantIdsSet.has(m.restaurantId), + ) + ) { + throw new BadRequestException( + "errors.dishes.you-dont-have-rights-to-the-provided-menu", + { + property: "menuId", + }, + ); + } + } + + return true; + } + + private async validateCategoryIds(menuId: string, categoryIds: string[]) { + const categories = await this.pg.query.dishCategories.findMany({ + where: (dishCategories, { and, inArray }) => + and( + eq(dishCategories.menuId, menuId), + inArray(dishCategories.id, categoryIds), + ), + }); + + if (categories.length !== categoryIds.length) { + throw new BadRequestException("errors.dishes.invalid-category-ids"); + } + } + + public async create( + dto: CreateDishDto, + options: { worker: RequestWorker }, + ): Promise { + const { categoryIds, ...payload } = dto; + const { worker } = options; + + // Validate menu id + await this.validateMenuId(dto.menuId, worker); + await this.validateCategoryIds(dto.menuId, categoryIds); + + const dish = await this.pg.transaction(async (tx) => { + const [dish] = await tx + .insert(schema.dishes) + .values({ + ...payload, + }) + .returning({ + id: schema.dishes.id, + }); + + if (!dish) { + throw new ServerErrorException("Failed to create dish"); + } + + await tx.insert(schema.dishesToDishCategories).values( + categoryIds.map((id) => ({ + dishId: dish.id, + dishCategoryId: id, + })), + ); + + return dish; + }); + + return this.findById(dish.id); + } + + public async update( + id: string, + dto: UpdateDishDto, + options: { worker: RequestWorker }, + ): Promise { + const { categoryIds, ...payload } = dto; + const { worker } = options; + + worker; + + const dish = await this.pg.query.dishes.findFirst({ + where: (dishes, { eq }) => eq(dishes.id, id), + columns: { + menuId: true, + }, + }); + + if (!dish) { + throw new NotFoundException(); + } + + // We need to make sure that worker has rights to the menu and can update dish + if (dish.menuId) { + await this.validateMenuId(dish.menuId, worker); + } + + if (categoryIds && dish.menuId) { + await this.validateCategoryIds(dish.menuId, categoryIds); + } + + if (Object.keys(payload).length === 0) { + throw new BadRequestException( + "errors.dishes.you-should-provide-at-least-one-field-to-update", + ); + } + + await this.pg.transaction(async (tx) => { + await tx + .update(schema.dishes) + .set(payload) + .where(eq(schema.dishes.id, id)); + + if (categoryIds) { + await tx + .delete(schema.dishesToDishCategories) + .where(eq(schema.dishesToDishCategories.dishId, id)); + + await tx.insert(schema.dishesToDishCategories).values( + categoryIds.map((dishCategoryId) => ({ + dishId: id, + dishCategoryId, + })), + ); + } + }); + + return this.findById(id); + } + + public async findById(id: string): Promise { + const result = await this.pg.query.dishes.findFirst({ + where: eq(schema.dishes.id, id), + with: { + dishesToDishCategories: { + columns: {}, + with: { + dishCategory: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + dishesToImages: { + with: { + imageFile: true, + }, + }, + }, + }); + + if (!result) { + return undefined; + } + + return { + ...result, + images: result.dishesToImages + .sort((a, b) => a.sortIndex - b.sortIndex) + .map((di) => ({ + ...di.imageFile, + alt: di.alt, + sortIndex: di.sortIndex, + })), + categories: result.dishesToDishCategories.map((dc) => dc.dishCategory), + }; + } +} diff --git a/src/dishes/@/dtos/create-dish.dto.ts b/src/dishes/@/dtos/create-dish.dto.ts new file mode 100644 index 0000000..d27da94 --- /dev/null +++ b/src/dishes/@/dtos/create-dish.dto.ts @@ -0,0 +1,40 @@ +import { IsUUID } from "@i18n-class-validator"; +import { + ApiProperty, + IntersectionType, + PartialType, + PickType, +} from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +import { DishEntity } from "../entities/dish.entity"; + +export class CreateDishDto extends IntersectionType( + PickType(DishEntity, ["name", "cookingTimeInMin", "weight", "weightMeasure"]), + PartialType( + PickType(DishEntity, [ + "note", + "amountPerItem", + "isLabelPrintingEnabled", + "printLabelEveryItem", + "isPublishedInApp", + "isPublishedAtSite", + ]), + ), +) { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + menuId: string; + + @Expose() + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: "Unique identifier of the dish category", + example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + }) + categoryIds: string[]; +} diff --git a/src/dishes/@/dtos/update-dish.dto.ts b/src/dishes/@/dtos/update-dish.dto.ts new file mode 100644 index 0000000..df036b7 --- /dev/null +++ b/src/dishes/@/dtos/update-dish.dto.ts @@ -0,0 +1,9 @@ +import { OmitType, PartialType } from "@nestjs/swagger"; + +import { CreateDishDto } from "./create-dish.dto"; + +export class UpdateDishDto extends PartialType( + OmitType(CreateDishDto, ["menuId"]), +) { + updatedAt?: Date; +} diff --git a/src/dishes/@/entities/dish-image.entity.ts b/src/dishes/@/entities/dish-image.entity.ts new file mode 100644 index 0000000..da5e9bb --- /dev/null +++ b/src/dishes/@/entities/dish-image.entity.ts @@ -0,0 +1,22 @@ +import { IsNumber, IsString } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { FileEntity } from "src/files/entitites/file.entity"; + +export class DishImageEntity extends FileEntity { + @Expose() + @IsString() + @ApiProperty({ + description: "Alternative text for the image", + example: "Image of a delicious dish", + }) + alt: string; + + @Expose() + @IsNumber() + @ApiProperty({ + description: "Sort order index of the image", + example: 0, + }) + sortIndex: number; +} diff --git a/src/dishes/@/entities/dish.entity.ts b/src/dishes/@/entities/dish.entity.ts new file mode 100644 index 0000000..b6c858a --- /dev/null +++ b/src/dishes/@/entities/dish.entity.ts @@ -0,0 +1,157 @@ +import { + IsBoolean, + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { IDish, ZodWeightMeasureEnum } from "@postgress-db/schema/dishes"; +import { Expose, Type } from "class-transformer"; +import { DishCategoryEntity } from "src/dish-categories/entities/dish-category.entity"; +import { DishImageEntity } from "src/dishes/@/entities/dish-image.entity"; + +export class DishDishCategoryEntity extends PickType(DishCategoryEntity, [ + "id", + "name", +]) {} + +export class DishEntity implements IDish { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the dish", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @Expose() + @IsOptional() + @IsUUID() + @ApiPropertyOptional({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + menuId: string | null; + + @Expose() + @IsString() + @ApiProperty({ + description: "Name of the dish", + example: "Chicken Kiev", + }) + name: string; + + @Expose() + @IsString() + @ApiProperty({ + description: "Note for internal use", + example: "Needs to be prepared 2 hours in advance", + }) + note: string; + + @Expose() + @IsNumber() + @ApiProperty({ + description: "Time needed for cooking in minutes", + example: 30, + }) + cookingTimeInMin: number; + + @Expose() + @IsNumber() + @ApiProperty({ + description: "Amount of pieces per one item", + example: 1, + }) + amountPerItem: number; + + @Expose() + @IsNumber() + @ApiProperty({ + description: "Weight of the dish", + example: 250, + }) + weight: number; + + @Expose() + @IsEnum(ZodWeightMeasureEnum.Enum) + @ApiProperty({ + description: "Weight measure unit", + enum: ZodWeightMeasureEnum.Enum, + example: ZodWeightMeasureEnum.Enum.grams, + examples: Object.values(ZodWeightMeasureEnum.Enum), + }) + weightMeasure: typeof ZodWeightMeasureEnum._type; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Whether label printing is enabled for this dish", + example: true, + }) + isLabelPrintingEnabled: boolean; + + @Expose() + @IsNumber() + @ApiProperty({ + description: "Print label for every N items", + example: 1, + }) + printLabelEveryItem: number; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Whether the dish is published in the app", + example: true, + }) + isPublishedInApp: boolean; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Whether the dish is published on the site", + example: true, + }) + isPublishedAtSite: boolean; + + @Expose() + @ValidateNested() + @Type(() => DishImageEntity) + @ApiProperty({ + description: "Images associated with the dish", + type: [DishImageEntity], + }) + images: DishImageEntity[]; + + @Expose() + @ValidateNested() + @Type(() => DishDishCategoryEntity) + @ApiProperty({ + description: "Categories associated with the dish", + type: [DishDishCategoryEntity], + }) + categories: DishDishCategoryEntity[]; + + @Expose() + @IsISO8601() + @ApiProperty({ + description: "Date when dish was created", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + createdAt: Date; + + @Expose() + @IsISO8601() + @ApiProperty({ + description: "Date when dish was last updated", + example: new Date("2022-03-01T05:20:52.000Z"), + type: Date, + }) + updatedAt: Date; +} diff --git a/src/dishes/@/entities/dishes-paginated.entity.ts b/src/dishes/@/entities/dishes-paginated.entity.ts new file mode 100644 index 0000000..d9964eb --- /dev/null +++ b/src/dishes/@/entities/dishes-paginated.entity.ts @@ -0,0 +1,15 @@ +import { PaginationResponseDto } from "@core/dto/pagination-response.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; + +import { DishEntity } from "./dish.entity"; + +export class DishesPaginatedDto extends PaginationResponseDto { + @Expose() + @ApiProperty({ + description: "Array of dishes", + type: [DishEntity], + }) + @Type(() => DishEntity) + data: DishEntity[]; +} diff --git a/src/dishes/dishes.module.ts b/src/dishes/dishes.module.ts new file mode 100644 index 0000000..e0f6726 --- /dev/null +++ b/src/dishes/dishes.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { NestjsFormDataModule } from "nestjs-form-data"; +import { FilesModule } from "src/files/files.module"; + +import { DishesController } from "./@/dishes.controller"; +import { DishesService } from "./@/dishes.service"; +import { DishImagesController } from "./images/dish-images.controller"; +import { DishImagesService } from "./images/dish-images.service"; +import { DishPricelistController } from "./pricelist/dish-pricelist.controller"; +import { DishPricelistService } from "./pricelist/dish-pricelist.service"; + +@Module({ + imports: [DrizzleModule, FilesModule, NestjsFormDataModule], + controllers: [ + DishesController, + DishImagesController, + DishPricelistController, + ], + providers: [DishesService, DishImagesService, DishPricelistService], + exports: [DishesService, DishImagesService, DishPricelistService], +}) +export class DishesModule {} diff --git a/src/dishes/images/dish-images.controller.ts b/src/dishes/images/dish-images.controller.ts new file mode 100644 index 0000000..d402eb7 --- /dev/null +++ b/src/dishes/images/dish-images.controller.ts @@ -0,0 +1,81 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { Body, Delete, Param, Post, Put } from "@nestjs/common"; +import { + ApiConsumes, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { IWorker } from "@postgress-db/schema/workers"; +import { FormDataRequest } from "nestjs-form-data"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; + +import { DishImageEntity } from "../@/entities/dish-image.entity"; + +import { DishImagesService } from "./dish-images.service"; +import { UpdateDishImageDto } from "./dto/update-dish-image.dto"; +import { UploadDishImageDto } from "./dto/upload-dish-image.dto"; + +@Controller("dishes/:id/images", { + tags: ["dishes"], +}) +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class DishImagesController { + constructor(private readonly dishImagesService: DishImagesService) {} + + @EnableAuditLog() + @Post() + @FormDataRequest() + @Serializable(DishImageEntity) + @ApiOperation({ summary: "Upload an image for a dish" }) + @ApiConsumes("multipart/form-data") + @ApiOkResponse({ + description: "Image has been successfully uploaded", + type: DishImageEntity, + }) + async uploadImage( + @Param("id") dishId: string, + @Body() dto: UploadDishImageDto, + @Worker() worker: IWorker, + ) { + return this.dishImagesService.uploadImage(dishId, dto.file, worker, { + alt: dto.alt, + }); + } + + @EnableAuditLog() + @Put(":imageId") + @Serializable(DishImageEntity) + @ApiOperation({ summary: "Update dish image details" }) + @ApiOkResponse({ + description: "Image has been successfully updated", + type: DishImageEntity, + }) + async updateImage( + @Param("id") dishId: string, + @Param("imageId") imageId: string, + @Body() dto: UpdateDishImageDto, + ) { + return this.dishImagesService.updateImage(dishId, imageId, { + alt: dto.alt, + sortIndex: dto.sortIndex, + }); + } + + @EnableAuditLog() + @Delete(":imageId") + @ApiOperation({ summary: "Delete an image from dish" }) + @ApiOkResponse({ + description: "Image has been successfully deleted", + }) + async deleteImage( + @Param("id") dishId: string, + @Param("imageId") imageId: string, + ) { + await this.dishImagesService.deleteImage(dishId, imageId); + } +} diff --git a/src/dishes/images/dish-images.service.ts b/src/dishes/images/dish-images.service.ts new file mode 100644 index 0000000..902f047 --- /dev/null +++ b/src/dishes/images/dish-images.service.ts @@ -0,0 +1,170 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { IWorker } from "@postgress-db/schema/workers"; +import { count, eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { MemoryStoredFile } from "nestjs-form-data"; +import { PG_CONNECTION } from "src/constants"; +import { FilesService } from "src/files/files.service"; + +import { DishImageEntity } from "../@/entities/dish-image.entity"; + +@Injectable() +export class DishImagesService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly filesService: FilesService, + ) {} + + async uploadImage( + dishId: string, + file: MemoryStoredFile, + worker: IWorker, + options?: { + alt?: string; + }, + ): Promise { + // Get the next sort index + const result = await this.pg + .select({ + value: count(), + }) + .from(schema.dishesToImages) + .where(eq(schema.dishesToImages.dishId, dishId)); + + const sortIndex = result[0].value + 1; + + // Upload the file first + const uploadedFile = await this.filesService.uploadFile(file, { + uploadedByUserId: worker.id, + }); + + // Create the dish-to-image relation + await this.pg.insert(schema.dishesToImages).values({ + dishId, + imageFileId: uploadedFile.id, + alt: options?.alt ?? "", + sortIndex, + }); + + // Return the combined entity + return { + ...uploadedFile, + alt: options?.alt ?? "", + sortIndex, + }; + } + + async updateImage( + dishId: string, + imageId: string, + data: { + alt?: string; + sortIndex?: number; + }, + ): Promise { + // Get current image details + const currentImage = await this.pg.query.dishesToImages.findFirst({ + where: + eq(schema.dishesToImages.dishId, dishId) && + eq(schema.dishesToImages.imageFileId, imageId), + }); + + if (!currentImage) { + throw new BadRequestException("Image not found"); + } + + const updateData: Record = {}; + + // Handle alt text update if provided + if (data.alt !== undefined) { + updateData.alt = data.alt; + } + + // Handle sort index swap if provided + if (data.sortIndex !== undefined) { + // Find image with target sort index + const targetImage = await this.pg.query.dishesToImages.findFirst({ + where: + eq(schema.dishesToImages.dishId, dishId) && + eq(schema.dishesToImages.sortIndex, data.sortIndex), + }); + + if (!targetImage) { + throw new BadRequestException( + `No image found with sort index ${data.sortIndex}`, + ); + } + + // Swap sort indexes + await this.pg + .update(schema.dishesToImages) + .set({ + sortIndex: currentImage.sortIndex, + }) + .where( + eq(schema.dishesToImages.dishId, dishId) && + eq(schema.dishesToImages.imageFileId, targetImage.imageFileId), + ); + + updateData.sortIndex = data.sortIndex; + } + + console.log(data, updateData); + + // Only update if we have changes + if (Object.keys(updateData).length === 0) { + throw new BadRequestException("No values to update"); + } + + // Update the current image + const [updated] = await this.pg + .update(schema.dishesToImages) + .set(updateData) + .where( + eq(schema.dishesToImages.dishId, dishId) && + eq(schema.dishesToImages.imageFileId, imageId), + ) + .returning(); + + // Get the file details + const file = await this.pg.query.files.findFirst({ + where: eq(schema.files.id, imageId), + }); + + if (!file) { + throw new BadRequestException("File not found"); + } + + // Return the combined entity + return { + ...file, + alt: updated.alt, + sortIndex: updated.sortIndex, + }; + } + + async deleteImage(dishId: string, imageId: string): Promise { + // First delete the relation + const [deleted] = await this.pg + .delete(schema.dishesToImages) + .where( + eq(schema.dishesToImages.dishId, dishId) && + eq(schema.dishesToImages.imageFileId, imageId), + ) + .returning(); + + if (!deleted) { + throw new BadRequestException("Image not found"); + } + + try { + // Then delete the actual file + await this.filesService.deleteFile(imageId); + } catch (error) { + throw new ServerErrorException("Failed to delete file"); + } + } +} diff --git a/src/dishes/images/dto/update-dish-image.dto.ts b/src/dishes/images/dto/update-dish-image.dto.ts new file mode 100644 index 0000000..d5d7be1 --- /dev/null +++ b/src/dishes/images/dto/update-dish-image.dto.ts @@ -0,0 +1,23 @@ +import { IsNumber, IsOptional, IsString } from "@i18n-class-validator"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class UpdateDishImageDto { + @ApiPropertyOptional({ + description: "Alternative text for the image", + example: "Delicious pasta dish with tomato sauce", + }) + @Expose() + @IsOptional() + @IsString() + alt?: string; + + @ApiPropertyOptional({ + description: "Sort order index to swap with", + example: 2, + }) + @Expose() + @IsOptional() + @IsNumber() + sortIndex?: number; +} diff --git a/src/dishes/images/dto/upload-dish-image.dto.ts b/src/dishes/images/dto/upload-dish-image.dto.ts new file mode 100644 index 0000000..eef573a --- /dev/null +++ b/src/dishes/images/dto/upload-dish-image.dto.ts @@ -0,0 +1,31 @@ +import { IsOptional, IsString } from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { + HasMimeType, + IsFile, + MaxFileSize, + MemoryStoredFile, +} from "nestjs-form-data"; + +export class UploadDishImageDto { + @ApiProperty({ + type: "file", + description: "Image file to upload (JPEG, PNG, JPG). Max size: 2MB", + required: true, + }) + @Expose() + @IsFile() + @MaxFileSize(2e6) + @HasMimeType(["image/jpeg", "image/png", "image/jpg"]) + file: MemoryStoredFile; + + @ApiPropertyOptional({ + description: "Alternative text for the image", + example: "Delicious pasta dish with tomato sauce", + }) + @Expose() + @IsOptional() + @IsString() + alt?: string; +} diff --git a/src/dishes/pricelist/dish-pricelist.controller.ts b/src/dishes/pricelist/dish-pricelist.controller.ts new file mode 100644 index 0000000..be94e2c --- /dev/null +++ b/src/dishes/pricelist/dish-pricelist.controller.ts @@ -0,0 +1,51 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Body, Get, Param, Put } from "@nestjs/common"; +import { ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; + +import { DishPricelistService } from "./dish-pricelist.service"; +import { UpdateDishPricelistDto } from "./dto/update-dish-pricelist.dto"; +import DishPricelistItemEntity from "./entities/dish-pricelist-item.entity"; + +@Controller("dishes/:id/pricelist", { + tags: ["dishes"], +}) +export class DishPricelistController { + constructor(private readonly dishPricelistService: DishPricelistService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(DishPricelistItemEntity) + @ApiOperation({ + summary: "Get dish pricelist", + }) + @ApiResponse({ + status: 200, + description: "Returns dish pricelist items", + type: [DishPricelistItemEntity], + }) + async getPricelist(@Param("id") dishId: string) { + return this.dishPricelistService.getPricelist(dishId); + } + + @EnableAuditLog() + @Put(":restaurantId") + @Serializable(DishPricelistItemEntity) + @ApiOperation({ + summary: "Update dish pricelist for restaurant", + }) + @ApiResponse({ + status: 200, + description: "Returns updated dish pricelist item", + type: DishPricelistItemEntity, + }) + async updatePricelist( + @Param("id") dishId: string, + @Param("restaurantId") restaurantId: string, + @Body() dto: UpdateDishPricelistDto, + ) { + // TODO: Add validation of worker role/restaurant access + return this.dishPricelistService.updatePricelist(dishId, restaurantId, dto); + } +} diff --git a/src/dishes/pricelist/dish-pricelist.service.ts b/src/dishes/pricelist/dish-pricelist.service.ts new file mode 100644 index 0000000..8babec0 --- /dev/null +++ b/src/dishes/pricelist/dish-pricelist.service.ts @@ -0,0 +1,214 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { and, eq, inArray } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; + +import { UpdateDishPricelistDto } from "./dto/update-dish-pricelist.dto"; +import { IDishPricelistItem } from "./entities/dish-pricelist-item.entity"; + +@Injectable() +export class DishPricelistService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + async getPricelist(dishId: string): Promise { + const dish = await this.pg.query.dishes.findFirst({ + where: (dishes, { eq }) => eq(dishes.id, dishId), + columns: { + menuId: true, + }, + with: { + menu: { + columns: {}, + with: { + dishesMenusToRestaurants: { + columns: { + restaurantId: true, + }, + }, + }, + }, + }, + }); + + const restaurantIds = (dish?.menu?.dishesMenusToRestaurants ?? []).map( + (r) => r.restaurantId, + ); + + if (restaurantIds.length === 0) { + return []; + } + + // Get all restaurants with their workshops and dish relationships + const restaurants = await this.pg.query.restaurants.findMany({ + where: (restaurants, { inArray }) => + inArray(restaurants.id, restaurantIds ?? []), + with: { + workshops: { + with: { + dishesToWorkshops: { + where: eq(schema.dishesToWorkshops.dishId, dishId), + }, + }, + }, + dishesToRestaurants: { + where: eq(schema.dishesToRestaurants.dishId, dishId), + }, + }, + }); + + // Transform the data into the required format + return restaurants.map((restaurant): IDishPricelistItem => { + const dishToRestaurant = restaurant.dishesToRestaurants[0]; + + return { + restaurantId: restaurant.id, + restaurantName: restaurant.name, + workshops: restaurant.workshops.map((workshop) => { + const dishWorkshop = workshop.dishesToWorkshops[0]; + return { + workshopId: workshop.id, + workshopName: workshop.name, + isActive: !!dishWorkshop, + createdAt: dishWorkshop?.createdAt ?? null, + }; + }), + price: parseFloat(dishToRestaurant?.price ?? "0"), + currency: dishToRestaurant?.currency ?? "EUR", + isInStoplist: dishToRestaurant?.isInStopList ?? false, + createdAt: dishToRestaurant?.createdAt ?? null, + updatedAt: dishToRestaurant?.updatedAt ?? null, + }; + }); + } + + async updatePricelist( + dishId: string, + restaurantId: string, + dto: UpdateDishPricelistDto, + ): Promise { + const dish = await this.pg.query.dishes.findFirst({ + where: eq(schema.dishes.id, dishId), + columns: {}, + with: { + menu: { + columns: {}, + with: { + dishesMenusToRestaurants: { + where: eq( + schema.dishesMenusToRestaurants.restaurantId, + restaurantId, + ), + columns: { + restaurantId: true, + }, + with: { + restaurant: { + columns: { + currency: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if ( + !dish || + !dish.menu || + dish.menu.dishesMenusToRestaurants.length === 0 + ) { + throw new BadRequestException( + "errors.dish-pricelist.provided-restaurant-dont-assigned-to-menu", + ); + } + + if ( + dish.menu.dishesMenusToRestaurants.some( + (r) => r.restaurant.currency !== dto.currency, + ) + ) { + throw new BadRequestException( + "errors.dish-pricelist.provided-currency-dont-match-restaurant-currency", + { + property: "currency", + }, + ); + } + + // First verify that all workshopIds belong to the restaurant + const workshops = await this.pg.query.restaurantWorkshops.findMany({ + where: eq(schema.restaurantWorkshops.restaurantId, restaurantId), + }); + + const validWorkshopIds = new Set(workshops.map((w) => w.id)); + const invalidWorkshopIds = dto.workshopIds.filter( + (id) => !validWorkshopIds.has(id), + ); + + if (invalidWorkshopIds.length > 0) { + throw new BadRequestException( + "errors.dish-pricelist.provided-workshop-ids-dont-belong-to-restaurant", + { + property: "workshopIds", + }, + ); + } + + await this.pg.transaction(async (tx) => { + // Update or create dish-restaurant relation + await tx + .insert(schema.dishesToRestaurants) + .values({ + dishId, + restaurantId, + price: dto.price.toString(), + currency: dto.currency, + isInStopList: dto.isInStoplist, + }) + .onConflictDoUpdate({ + target: [ + schema.dishesToRestaurants.dishId, + schema.dishesToRestaurants.restaurantId, + ], + set: { + price: dto.price.toString(), + currency: dto.currency, + isInStopList: dto.isInStoplist, + updatedAt: new Date(), + }, + }); + + // Delete all existing workshop relations for this dish in this restaurant + await tx.delete(schema.dishesToWorkshops).where( + and( + eq(schema.dishesToWorkshops.dishId, dishId), + inArray( + schema.dishesToWorkshops.workshopId, + workshops.map((w) => w.id), + ), + ), + ); + + // Create new workshop relations + if (dto.workshopIds.length > 0) { + await tx.insert(schema.dishesToWorkshops).values( + dto.workshopIds.map((workshopId) => ({ + dishId, + workshopId, + })), + ); + } + }); + + // Return updated pricelist item + const [updatedItem] = await this.getPricelist(dishId); + + return updatedItem; + } +} diff --git a/src/dishes/pricelist/dto/update-dish-pricelist.dto.ts b/src/dishes/pricelist/dto/update-dish-pricelist.dto.ts new file mode 100644 index 0000000..f4c69f2 --- /dev/null +++ b/src/dishes/pricelist/dto/update-dish-pricelist.dto.ts @@ -0,0 +1,47 @@ +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsUUID, +} from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { ICurrency } from "@postgress-db/schema/general"; +import { Expose } from "class-transformer"; + +export class UpdateDishPricelistDto { + @IsNumber() + @Expose() + @ApiProperty({ + description: "Price of the dish", + example: 1000, + }) + price: number; + + @IsEnum(["EUR", "USD", "RUB"]) + @Expose() + @ApiProperty({ + description: "Currency of the price", + example: "EUR", + enum: ["EUR", "USD", "RUB"], + }) + currency: ICurrency; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the dish is in stoplist", + example: false, + }) + isInStoplist: boolean; + + @IsArray() + @IsUUID(undefined, { each: true }) + @Expose() + @ApiProperty({ + description: "Array of workshop IDs to be active", + type: [String], + example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + }) + workshopIds: string[]; +} diff --git a/src/dishes/pricelist/entities/dish-pricelist-item.entity.ts b/src/dishes/pricelist/entities/dish-pricelist-item.entity.ts new file mode 100644 index 0000000..5e63b2c --- /dev/null +++ b/src/dishes/pricelist/entities/dish-pricelist-item.entity.ts @@ -0,0 +1,133 @@ +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsString, + IsUUID, + ValidateNested, +} from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { ICurrency } from "@postgress-db/schema/general"; +import { Expose, Type } from "class-transformer"; + +export interface IDishPricelistWorkshop { + workshopId: string; + workshopName: string; + isActive: boolean; + createdAt: Date | null; +} + +export interface IDishPricelistItem { + restaurantId: string; + restaurantName: string; + workshops: IDishPricelistWorkshop[]; + price: number; + currency: ICurrency; + isInStoplist: boolean; + createdAt: Date | null; + updatedAt: Date | null; +} + +export class DishPricelistWorkshopEntity implements IDishPricelistWorkshop { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the workshop", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workshopId: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the workshop", + example: "Kitchen", + }) + workshopName: string; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the workshop is active for this dish", + example: true, + }) + isActive: boolean; + + @Expose() + @ApiProperty({ + description: "When the workshop was assigned to the dish", + example: "2024-03-20T12:00:00Z", + nullable: true, + }) + createdAt: Date | null; +} + +export default class DishPricelistItemEntity implements IDishPricelistItem { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the restaurant", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + restaurantId: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the restaurant", + example: "My Restaurant", + }) + restaurantName: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DishPricelistWorkshopEntity) + @Expose() + @ApiProperty({ + description: "List of workshops where the dish is available", + type: [DishPricelistWorkshopEntity], + }) + workshops: DishPricelistWorkshopEntity[]; + + @IsNumber() + @Expose() + @ApiProperty({ + description: "Price of the dish", + example: 1000, + }) + price: number; + + @IsEnum(["EUR", "USD", "RUB"]) + @Expose() + @ApiProperty({ + description: "Currency of the price", + example: "EUR", + enum: ["EUR", "USD", "RUB"], + }) + currency: ICurrency; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the dish is in stoplist", + example: false, + }) + isInStoplist: boolean; + + @Expose() + @ApiProperty({ + description: "When the dish was added to restaurant", + example: "2024-03-20T12:00:00Z", + nullable: true, + }) + createdAt: Date | null; + + @Expose() + @ApiProperty({ + description: "When the dish-restaurant relation was last updated", + example: "2024-03-20T12:00:00Z", + nullable: true, + }) + updatedAt: Date | null; +} diff --git a/src/drizzle/drizzle.module.ts b/src/drizzle/drizzle.module.ts deleted file mode 100644 index 7ed41c1..0000000 --- a/src/drizzle/drizzle.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Module } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; - -import { PG_CONNECTION } from "../constants"; - -import * as schema from "./schema"; - -@Module({ - providers: [ - { - provide: PG_CONNECTION, - inject: [ConfigService], - useFactory: async (configService: ConfigService) => { - const connectionString = configService.get("POSTGRESQL_URL"); - const pool = new Pool({ - connectionString, - ssl: process.env.NODE_ENV === "production" ? true : false, - }); - - return drizzle(pool, { schema }); - }, - }, - ], - exports: [PG_CONNECTION], -}) -export class DrizzleModule {} diff --git a/src/drizzle/migrations/meta/_journal.json b/src/drizzle/migrations/meta/_journal.json deleted file mode 100644 index b9863cb..0000000 --- a/src/drizzle/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "5", - "dialect": "pg", - "entries": [ - { - "idx": 0, - "version": "5", - "when": 1707137951517, - "tag": "0000_fantastic_nightmare", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/src/drizzle/schema/index.ts b/src/drizzle/schema/index.ts deleted file mode 100644 index 3673abf..0000000 --- a/src/drizzle/schema/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./restaurants"; -export * from "./workers"; -export * from "./sessions"; diff --git a/src/drizzle/schema/restaurants.ts b/src/drizzle/schema/restaurants.ts deleted file mode 100644 index 2c74073..0000000 --- a/src/drizzle/schema/restaurants.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { relations, sql } from "drizzle-orm"; -import { - boolean, - numeric, - pgTable, - text, - time, - timestamp, - uuid, -} from "drizzle-orm/pg-core"; - -import { workers } from "./workers"; - -export const restaurants = pgTable("restaurants", { - // Primary key - id: uuid("id").defaultRandom(), - - // Name of the restaurant // - name: text("name").notNull(), - - // Legal entity of the restaurant (can be a company or a person) // - legalEntity: text("legalEntity"), - - // Address of the restaurant // - address: text("address"), - latitude: numeric("latitude"), - longitude: numeric("longitude"), - - // Is the restaurant enabled? // - isEnabled: boolean("isEnabled").default(false), - - // Timestamps // - createdAt: timestamp("createdAt") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - - updatedAt: timestamp("updatedAt").notNull().defaultNow(), -}); - -export const restaurantHours = pgTable("restaurantHours", { - // Primary key // - id: uuid("id").defaultRandom(), - - // Restaurant // - restaurantId: uuid("restaurantId").notNull(), - - // Day of the week // - dayOfWeek: text("dayOfWeek").notNull(), - - // Opening and closing hours // - openingTime: time("openingTime").notNull(), - closingTime: time("closingTime").notNull(), - - isEnabled: boolean("isEnabled").default(true), - - // Timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), -}); - -export const restaurantRelations = relations(restaurants, ({ many }) => ({ - restaurantHours: many(restaurantHours), - workers: many(workers), -})); - -export const restaurantHourRelations = relations( - restaurantHours, - ({ one }) => ({ - restaurant: one(restaurants, { - fields: [restaurantHours.restaurantId], - references: [restaurants.id], - }), - }), -); - -export type IRestaurant = typeof restaurants.$inferSelect; -export type IRestaurantHours = typeof restaurantHours.$inferSelect; diff --git a/src/drizzle/schema/sessions.ts b/src/drizzle/schema/sessions.ts deleted file mode 100644 index 96acd03..0000000 --- a/src/drizzle/schema/sessions.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { relations } from "drizzle-orm"; -import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; - -import { workers } from "./workers"; - -export const sessions = pgTable("sessions", { - id: uuid("id").defaultRandom(), - workerId: uuid("workerId").notNull(), - httpAgent: text("httpAgent"), - ipAddress: text("ipAddress"), - token: text("token").notNull().unique(), - refreshedAt: timestamp("refreshedAt"), - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), -}); - -export const sessionRelations = relations(sessions, ({ one }) => ({ - worker: one(workers, { - fields: [sessions.workerId], - references: [workers.id], - }), -})); - -export type ISession = typeof sessions.$inferSelect; diff --git a/src/drizzle/schema/workers.ts b/src/drizzle/schema/workers.ts deleted file mode 100644 index 6d3b656..0000000 --- a/src/drizzle/schema/workers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { relations } from "drizzle-orm"; -import { - boolean, - pgEnum, - pgTable, - text, - timestamp, - uuid, -} from "drizzle-orm/pg-core"; -import { z } from "zod"; - -import { restaurants } from "./restaurants"; -import { sessions } from "./sessions"; - -export const workerRoleEnum = pgEnum("workerRoleEnum", [ - "SYSTEM_ADMIN" as const, - "CHIEF_ADMIN", - "ADMIN", - "KITCHENER", - "WAITER", - "CASHIER", - "DISPATCHER", - "COURIER", -]); - -export const ZodWorkerRole = z.enum(workerRoleEnum.enumValues); - -export const workers = pgTable("workers", { - id: uuid("id").defaultRandom(), - name: text("name"), - restaurantId: uuid("restaurantId"), - login: text("login").unique().notNull(), - role: workerRoleEnum("role").notNull(), - passwordHash: text("passwordHash").notNull(), - isBlocked: boolean("isBlocked").default(false), - hiredAt: timestamp("hiredAt"), - firedAt: timestamp("firedAt"), - onlineAt: timestamp("onlineAt"), - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), -}); - -export const workerRelations = relations(workers, ({ one, many }) => ({ - restaurant: one(restaurants, { - fields: [workers.restaurantId], - references: [restaurants.id], - }), - sessions: many(sessions, { - relationName: "sessions", - }), -})); - -export type IWorker = typeof workers.$inferSelect; -export type WorkerRole = typeof ZodWorkerRole._type; - -export const workerRoleRank: Record = { - KITCHENER: 0, - WAITER: 0, - CASHIER: 0, - DISPATCHER: 0, - COURIER: 0, - ADMIN: 1, - CHIEF_ADMIN: 2, - SYSTEM_ADMIN: 3, -}; diff --git a/src/drizzle/seed.ts b/src/drizzle/seed.ts deleted file mode 100644 index eaf6d5e..0000000 --- a/src/drizzle/seed.ts +++ /dev/null @@ -1,31 +0,0 @@ -import dotenv from "dotenv"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; -import { seedDatabase } from "test/helpers/seed"; - -import * as schema from "./schema"; - -dotenv.config(); - -console.log(process.env.POSTGRESQL_URL); - -export async function seed() { - const pool = new Pool({ - connectionString: process.env.POSTGRESQL_URL, - }); - - const db = drizzle(pool, { schema }); - - console.log("Seeding database..."); - - await seedDatabase(db); - - console.log("Done!"); - - process.exit(0); -} - -seed().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/files/dto/upload-form-data.dto.ts b/src/files/dto/upload-form-data.dto.ts new file mode 100644 index 0000000..cadc224 --- /dev/null +++ b/src/files/dto/upload-form-data.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { + HasMimeType, + IsFile, + MaxFileSize, + MemoryStoredFile, +} from "nestjs-form-data"; + +export class UploadFormDataDto { + @ApiProperty({ + type: "file", + description: "Image file to upload (JPEG, PNG, JPG). Max size: 2MB", + required: true, + }) + @Expose() + @IsFile() + @MaxFileSize(2e6) + @HasMimeType(["image/jpeg", "image/png", "image/jpg"]) + file: MemoryStoredFile; +} diff --git a/src/files/entitites/file.entity.ts b/src/files/entitites/file.entity.ts new file mode 100644 index 0000000..2268d18 --- /dev/null +++ b/src/files/entitites/file.entity.ts @@ -0,0 +1,103 @@ +import { + IsISO8601, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IFile } from "@postgress-db/schema/files"; +import { Expose } from "class-transformer"; + +export class FileEntity implements IFile { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the file", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsOptional() + @IsUUID() + @Expose() + @ApiPropertyOptional({ + description: "Group identifier for related files", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + groupId: string | null; + + @IsString() + @Expose() + @ApiProperty({ + description: "Original name of the uploaded file", + example: "menu-photo.jpg", + }) + originalName: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "MIME type of the file", + example: "image/jpeg", + }) + mimeType: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "File extension", + example: "jpg", + }) + extension: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the storage bucket", + example: "restaurant-photos", + }) + bucketName: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Storage region", + example: "eu-central-1", + }) + region: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Storage endpoint", + example: "https://toite.hel1.your-objectstorage.com", + }) + endpoint: string; + + @IsNumber() + @Expose() + @ApiProperty({ + description: "Size of the file in bytes", + example: 1024000, + }) + size: number; + + @IsOptional() + @IsUUID() + @Expose() + @ApiPropertyOptional({ + description: "ID of the user who uploaded the file", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + uploadedByUserId: string | null; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when file was uploaded", + example: new Date("2024-03-01T00:00:00.000Z"), + type: Date, + }) + createdAt: Date; +} diff --git a/src/files/files.controller.ts b/src/files/files.controller.ts new file mode 100644 index 0000000..9b48d12 --- /dev/null +++ b/src/files/files.controller.ts @@ -0,0 +1,50 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { Body, Delete, Param, Post } from "@nestjs/common"; +import { + ApiConsumes, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { IWorker } from "@postgress-db/schema/workers"; +import { FormDataRequest } from "nestjs-form-data"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { UploadFormDataDto } from "src/files/dto/upload-form-data.dto"; +import { FileEntity } from "src/files/entitites/file.entity"; +import { FilesService } from "src/files/files.service"; + +@Controller("files") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class FilesController { + constructor(private readonly filesService: FilesService) {} + + @EnableAuditLog() + @Post("upload") + @FormDataRequest() + @Serializable(FileEntity) + @ApiOperation({ summary: "Uploads a file" }) + @ApiConsumes("multipart/form-data") + @ApiOkResponse({ + description: "File has been successfully uploaded", + type: FileEntity, + }) + async uploadFile(@Body() dto: UploadFormDataDto, @Worker() worker: IWorker) { + return this.filesService.uploadFile(dto.file, { + uploadedByUserId: worker.id, + }); + } + + @EnableAuditLog() + @Delete(":id") + @ApiOperation({ summary: "Deletes a file" }) + @ApiOkResponse({ + description: "File has been successfully deleted", + }) + async deleteFile(@Param("id") id: string) { + return this.filesService.deleteFile(id); + } +} diff --git a/src/files/files.module.ts b/src/files/files.module.ts new file mode 100644 index 0000000..356bdc0 --- /dev/null +++ b/src/files/files.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { NestjsFormDataModule } from "nestjs-form-data"; +import { S3Module } from "src/@base/s3/s3.module"; +import { FilesController } from "src/files/files.controller"; +import { FilesService } from "src/files/files.service"; + +@Module({ + imports: [S3Module, DrizzleModule, NestjsFormDataModule], + controllers: [FilesController], + providers: [FilesService], + exports: [FilesService], +}) +export class FilesModule {} diff --git a/src/files/files.service.ts b/src/files/files.service.ts new file mode 100644 index 0000000..2174940 --- /dev/null +++ b/src/files/files.service.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { MemoryStoredFile } from "nestjs-form-data"; +import { S3Service } from "src/@base/s3/s3.service"; +import { PG_CONNECTION } from "src/constants"; +import { FileEntity } from "src/files/entitites/file.entity"; + +@Injectable() +export class FilesService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly s3Service: S3Service, + ) {} + + async uploadFile( + file: MemoryStoredFile, + options?: { uploadedByUserId?: string }, + ): Promise { + const { uploadedByUserId } = options ?? {}; + const { id, extension } = await this.s3Service.uploadFile(file); + + const dbFile = await this.pg + .insert(schema.files) + .values({ + id, + originalName: file.originalName, + mimeType: file.mimeType, + extension, + bucketName: this.s3Service.bucketName, + region: this.s3Service.region, + endpoint: this.s3Service.endpoint, + size: file.size, + uploadedByUserId, + }) + .returning(); + + return dbFile[0]; + } + + async deleteFile(id: string) { + const file = await this.pg.query.files.findFirst({ + where: eq(schema.files.id, id), + }); + + await this.s3Service.deleteFile( + `${id}${file?.extension}`, + file?.bucketName, + ); + + if (file) { + await this.pg.delete(schema.files).where(eq(schema.files.id, id)); + } + } +} diff --git a/src/guests/dtos/create-guest.dto.ts b/src/guests/dtos/create-guest.dto.ts new file mode 100644 index 0000000..62a34a1 --- /dev/null +++ b/src/guests/dtos/create-guest.dto.ts @@ -0,0 +1,7 @@ +import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; +import { GuestEntity } from "src/guests/entities/guest.entity"; + +export class CreateGuestDto extends IntersectionType( + PickType(GuestEntity, ["name", "phone"]), + PartialType(PickType(GuestEntity, ["email", "bonusBalance"])), +) {} diff --git a/src/guests/dtos/find-or-create.dto.ts b/src/guests/dtos/find-or-create.dto.ts new file mode 100644 index 0000000..9140ef5 --- /dev/null +++ b/src/guests/dtos/find-or-create.dto.ts @@ -0,0 +1,6 @@ +import { IntersectionType, PickType } from "@nestjs/swagger"; +import { GuestEntity } from "src/guests/entities/guest.entity"; + +export class FindOrCreateGuestDto extends IntersectionType( + PickType(GuestEntity, ["phone"]), +) {} diff --git a/src/guests/dtos/update-guest.dto.ts b/src/guests/dtos/update-guest.dto.ts new file mode 100644 index 0000000..8cfd290 --- /dev/null +++ b/src/guests/dtos/update-guest.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateGuestDto } from "src/guests/dtos/create-guest.dto"; + +export class UpdateGuestDto extends PartialType(CreateGuestDto) { + updatedAt?: Date; +} diff --git a/src/guests/entities/guest.entity.ts b/src/guests/entities/guest.entity.ts new file mode 100644 index 0000000..6988c40 --- /dev/null +++ b/src/guests/entities/guest.entity.ts @@ -0,0 +1,77 @@ +import { IsPhoneNumber } from "@core/decorators/is-phone.decorator"; +import { IsISO8601, IsNumber, IsString, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IGuest } from "@postgress-db/schema/guests"; +import { Expose } from "class-transformer"; + +export class GuestEntity implements IGuest { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the guest", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @Expose() + @IsString() + @ApiProperty({ + description: "Name of the guest", + example: "John Doe", + }) + name: string; + + @Expose() + @IsString() + @IsPhoneNumber() + @ApiProperty({ + description: "Phone number of the guest", + example: "+1234567890", + }) + phone: string; + + @Expose() + @IsString() + @ApiProperty({ + description: "Email address of the guest", + example: "john.doe@example.com", + nullable: true, + }) + @ApiPropertyOptional() + email: string | null; + + @Expose() + @IsNumber() + @ApiProperty({ + description: "Guest's bonus balance", + example: 100, + }) + bonusBalance: number; + + @Expose() + @IsISO8601() + @ApiProperty({ + description: "Date of guest's last visit", + example: new Date(), + type: Date, + }) + lastVisitAt: Date; + + @Expose() + @IsISO8601() + @ApiProperty({ + description: "Date when guest was created", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + createdAt: Date; + + @Expose() + @IsISO8601() + @ApiProperty({ + description: "Date when guest was last updated", + example: new Date("2022-03-01T05:20:52.000Z"), + type: Date, + }) + updatedAt: Date; +} diff --git a/src/guests/entities/guests-paginated.entity.ts b/src/guests/entities/guests-paginated.entity.ts new file mode 100644 index 0000000..8eb945d --- /dev/null +++ b/src/guests/entities/guests-paginated.entity.ts @@ -0,0 +1,14 @@ +import { PaginationResponseDto } from "@core/dto/pagination-response.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { GuestEntity } from "src/guests/entities/guest.entity"; + +export class GuestsPaginatedDto extends PaginationResponseDto { + @Expose() + @ApiProperty({ + description: "Array of guests", + type: [GuestEntity], + }) + @Type(() => GuestEntity) + data: GuestEntity[]; +} diff --git a/src/guests/guests.controller.ts b/src/guests/guests.controller.ts new file mode 100644 index 0000000..02690ef --- /dev/null +++ b/src/guests/guests.controller.ts @@ -0,0 +1,177 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { FilterParams, IFilters } from "@core/decorators/filter.decorator"; +import { + IPagination, + PaginationParams, +} from "@core/decorators/pagination.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { ISorting, SortingParams } from "@core/decorators/sorting.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { Body, Get, Param, Post, Put } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { FindOrCreateGuestDto } from "src/guests/dtos/find-or-create.dto"; + +import { CreateGuestDto } from "./dtos/create-guest.dto"; +import { UpdateGuestDto } from "./dtos/update-guest.dto"; +import { GuestEntity } from "./entities/guest.entity"; +import { GuestsPaginatedDto } from "./entities/guests-paginated.entity"; +import { GuestsService } from "./guests.service"; + +@Controller("guests") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class GuestsController { + constructor(private readonly guestsService: GuestsService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @ApiOperation({ + summary: "Gets guests that available in system", + }) + @Serializable(GuestsPaginatedDto) + @ApiOkResponse({ + description: "Guests have been successfully fetched", + type: GuestsPaginatedDto, + }) + async findMany( + @SortingParams({ + fields: [ + "id", + "name", + "phone", + "email", + "bonusBalance", + "updatedAt", + "createdAt", + "lastVisitAt", + ], + }) + sorting: ISorting, + @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, + ): Promise { + const total = await this.guestsService.getTotalCount(filters); + const data = await this.guestsService.findMany({ + pagination, + sorting, + filters, + }); + + return { + data, + meta: { + ...pagination, + total, + }, + }; + } + + @EnableAuditLog() + @Post() + @Serializable(GuestEntity) + @ApiOperation({ summary: "Creates a new guest" }) + @ApiCreatedResponse({ description: "Guest has been successfully created" }) + async create(@Body() data: CreateGuestDto): Promise { + const guest = await this.guestsService.create(data); + + if (!guest) { + throw new BadRequestException("errors.guests.failed-to-create-guest"); + } + + return guest; + } + + @EnableAuditLog() + @Post("find-or-create") + @Serializable(GuestEntity) + @ApiOperation({ summary: "Finds a guest or creates a new one" }) + @ApiOkResponse({ + description: "Guest has been successfully fetched", + type: GuestEntity, + }) + async findOrCreate(@Body() data: FindOrCreateGuestDto): Promise { + const guest = await this.guestsService.findOrCreate(data); + + if (!guest) { + throw new BadRequestException(); + } + + return guest; + } + + @EnableAuditLog({ onlyErrors: true }) + @Get(":id") + @Serializable(GuestEntity) + @ApiOperation({ summary: "Gets a guest by id" }) + @ApiOkResponse({ + description: "Guest has been successfully fetched", + type: GuestEntity, + }) + @ApiNotFoundResponse({ + description: "Guest with this id doesn't exist", + }) + @ApiBadRequestResponse({ + description: "Id must be a string and provided", + }) + async findOne(@Param("id") id?: string): Promise { + if (!id) { + throw new BadRequestException( + "errors.common.id-must-be-a-string-and-provided", + ); + } + + const guest = await this.guestsService.findById(id); + + if (!guest) { + throw new NotFoundException("errors.guests.with-this-id-doesnt-exist"); + } + + return guest; + } + + @EnableAuditLog() + @Put(":id") + @Serializable(GuestEntity) + @ApiOperation({ summary: "Updates a guest by id" }) + @ApiOkResponse({ + description: "Guest has been successfully updated", + type: GuestEntity, + }) + @ApiNotFoundResponse({ + description: "Guest with this id doesn't exist", + }) + @ApiBadRequestResponse({ + description: "Id must be a string and provided", + }) + async update( + @Param("id") id: string, + @Body() data: UpdateGuestDto, + ): Promise { + if (!id) { + throw new BadRequestException( + "errors.common.id-must-be-a-string-and-provided", + ); + } + + const updatedGuest = await this.guestsService.update(id, { + ...data, + updatedAt: new Date(), + }); + + if (!updatedGuest) { + throw new NotFoundException("errors.guests.with-this-id-doesnt-exist"); + } + + return updatedGuest; + } +} diff --git a/src/guests/guests.module.ts b/src/guests/guests.module.ts new file mode 100644 index 0000000..e11084c --- /dev/null +++ b/src/guests/guests.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { GuestsController } from "src/guests/guests.controller"; +import { GuestsService } from "src/guests/guests.service"; + +@Module({ + imports: [DrizzleModule], + controllers: [GuestsController], + providers: [GuestsService], + exports: [GuestsService], +}) +export class GuestsModule {} diff --git a/src/guests/guests.service.ts b/src/guests/guests.service.ts new file mode 100644 index 0000000..289d456 --- /dev/null +++ b/src/guests/guests.service.ts @@ -0,0 +1,188 @@ +import { IFilters } from "@core/decorators/filter.decorator"; +import { + IPagination, + PAGINATION_DEFAULT_LIMIT, +} from "@core/decorators/pagination.decorator"; +import { ISorting } from "@core/decorators/sorting.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { DrizzleUtils } from "@postgress-db/drizzle-utils"; +import { schema } from "@postgress-db/drizzle.module"; +import { asc, count, desc, eq, sql } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js"; +import { PG_CONNECTION } from "src/constants"; +import { CreateGuestDto } from "src/guests/dtos/create-guest.dto"; +import { FindOrCreateGuestDto } from "src/guests/dtos/find-or-create.dto"; +import { UpdateGuestDto } from "src/guests/dtos/update-guest.dto"; +import { GuestEntity } from "src/guests/entities/guest.entity"; + +@Injectable() +export class GuestsService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + public async getTotalCount(filters?: IFilters): Promise { + const query = this.pg + .select({ + value: count(), + }) + .from(schema.guests); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(schema.guests, filters)); + } + + return await query.then((res) => res[0].value); + } + + public async findMany(options?: { + pagination?: IPagination; + sorting?: ISorting; + filters?: IFilters; + }): Promise { + const { pagination, sorting, filters } = options ?? {}; + + const query = this.pg.select().from(schema.guests); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(schema.guests, filters)); + } + + if (sorting) { + query.orderBy( + sorting.sortOrder === "asc" + ? asc(sql.identifier(sorting.sortBy)) + : desc(sql.identifier(sorting.sortBy)), + ); + } + + return await query + .limit(pagination?.size ?? PAGINATION_DEFAULT_LIMIT) + .offset(pagination?.offset ?? 0); + } + + private formatPhoneNumber(phone: string): string { + try { + const phoneNumber = parsePhoneNumber(phone); + if (!phoneNumber || !isValidPhoneNumber(phone)) { + throw new BadRequestException("errors.common.invalid-phone-number", { + property: "phone", + }); + } + // Format to E.164 format (e.g., +12133734253) + return phoneNumber.format("E.164"); + } catch (error) { + throw new BadRequestException( + "errors.common.invalid-phone-number-format", + { + property: "phone", + }, + ); + } + } + + public async create(dto: CreateGuestDto): Promise { + const formattedPhone = this.formatPhoneNumber(dto.phone); + + const guests = await this.pg + .insert(schema.guests) + .values({ + ...dto, + phone: formattedPhone, + lastVisitAt: new Date(), + bonusBalance: dto.bonusBalance ?? 0, + email: dto.email && dto.email.trim() !== "" ? dto.email : null, + }) + .returning(); + + const guest = guests[0]; + if (!guest) { + throw new ServerErrorException("errors.guests.failed-to-create-guest"); + } + + return guest; + } + + public async update( + id: string, + dto: UpdateGuestDto, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException( + "errors.common.atleast-one-field-should-be-provided", + ); + } + + // Format phone number if it's included in the update + const updateData = dto.phone + ? { ...dto, phone: this.formatPhoneNumber(dto.phone) } + : dto; + + await this.pg + .update(schema.guests) + .set({ + ...updateData, + email: + updateData.email && updateData.email.trim() !== "" + ? updateData.email + : null, + }) + .where(eq(schema.guests.id, id)); + + const result = await this.pg + .select() + .from(schema.guests) + .where(eq(schema.guests.id, id)) + .limit(1); + + return result[0]; + } + + public async remove() {} + + public async findById(id: string): Promise { + const result = await this.pg + .select() + .from(schema.guests) + .where(eq(schema.guests.id, id)) + .limit(1); + + return result?.[0]; + } + + public async findByPhoneNumber( + phone?: string | null, + ): Promise { + if (!phone) { + return undefined; + } + + const formattedPhone = this.formatPhoneNumber(phone); + + const result = await this.pg + .select() + .from(schema.guests) + .where(eq(schema.guests.phone, formattedPhone)) + .limit(1); + + return result?.[0]; + } + + public async findOrCreate( + dto: FindOrCreateGuestDto, + ): Promise { + const guest = await this.findByPhoneNumber(dto.phone); + + if (guest) { + return guest; + } + + return this.create({ + name: "-", + phone: dto.phone, + }); + } +} diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json new file mode 100644 index 0000000..06bd6e4 --- /dev/null +++ b/src/i18n/messages/en/errors.json @@ -0,0 +1,157 @@ +{ + "BAD_REQUEST": "Bad request", + "FORBIDDEN": "Forbidden", + "NOT_FOUND": "Not found", + "UNAUTHORIZED": "Unauthorized", + "CONFLICT": "Conflict", + "SERVER_ERROR": "Server error", + "common": { + "atleast-one-field-should-be-provided": "You should provide at least one field to update", + "id-must-be-a-string-and-provided": "Id must be a string and provided", + "invalid-phone-number": "Invalid phone number", + "invalid-phone-number-format": "Invalid phone number format", + "body-should-be-an-object": "Body should be an object", + "validation-error": "Validation error", + "forbidden-access-to-resource": "You don't have permission to access this resource", + "invalid-sort-by-field": "Invalid sortBy field", + "invalid-sort-order-field": "Invalid sortOrder field", + "invalid-pagination-size": "Invalid pagination size", + "invalid-pagination-page": "Invalid pagination page", + "invalid-pagination-params": "Invalid pagination params", + "invalid-filters-format": "Invalid filters format: should be a JSON string or an array", + "invalid-filter-condition": "Invalid filter condition", + "invalid-filter-format": "Invalid filter format: each filter must have field, value and condition", + "invalid-search-param": "Invalid search parameter", + "invalid-search-length": "Search length must be between {minLength} and {maxLength} characters", + "invalid-limit-value": "Invalid limit value", + "invalid-cursor-id": "Invalid cursor id", + "limit-too-big": "Limit can't be greater than 1000" + }, + "auth": { + "invalid-credentials": "Invalid credentials" + }, + "workers": { + "with-this-id-doesnt-exist": "Worker with this id doesn't exist", + "cant-create-system-admin": "You can't create system admin", + "not-enough-rights-to-create-worker-with-this-role": "You can't create worker with this role", + "role": { + "cant-assign-restaurant-to-this-role": "You can't assign restaurant to this role" + }, + "failed-to-create-worker": "Failed to create worker", + "worker-with-this-login-already-exists": "Worker with this login already exists" + }, + "restaurants": { + "provided-timezone-cant-be-set": "Provided timezone can't be set", + "with-this-id-doesnt-exist": "Restaurant with this id doesn't exist", + "with-provided-id-doesnt-exist": "Restaurant with provided id doesn't exist", + "you-dont-have-rights-to-that-restaurant": "You don't have rights to that restaurant", + "restaurant-was-assigned-to-some-owner-menus": "Restaurant was assigned to some owner menus" + }, + "restaurant-hours": { + "with-this-id-doesnt-exist": "Restaurant hours with this id doesn't exist" + }, + "restaurant-workshops": { + "with-this-id-doesnt-exist": "Restaurant workshop with this id doesn't exist" + }, + "guests": { + "failed-to-create-guest": "Failed to create guest", + "with-this-id-doesnt-exist": "Guest with this id doesn't exist" + }, + "dishes": { + "invalid-category-ids": "Invalid category ids", + "failed-to-create-dish": "Failed to create dish", + "with-this-id-doesnt-exist": "Dish with this id doesn't exist", + "provided-menu-doesnt-have-assigned-restaurants": "Provided menu doesn't have assigned restaurants", + "you-dont-have-rights-to-the-provided-menu": "You don't have rights to the provided menu", + "you-should-provide-at-least-one-field-to-update": "You should provide at least one field to update" + }, + "dish-categories": { + "failed-to-create-dish-category": "Failed to create dish category", + "with-this-id-doesnt-exist": "Dish category with this id doesn't exist" + }, + "dish-pricelist": { + "provided-workshop-ids-dont-belong-to-restaurant": "Provided workshop ids don't belong to restaurant", + "provided-restaurant-dont-assigned-to-menu": "Provided restaurant doesn't assigned to menu", + "provided-currency-dont-match-restaurant-currency": "Provided currency doesn't match restaurant currency" + }, + "orders": { + "with-this-id-doesnt-exist": "Order with this id doesn't exist", + "table-number-is-required": "Table number is required", + "phone-number-is-required": "Phone number is required", + "restaurant-is-required-for-banquet-or-hall": "Restaurant is required for banquet or hall", + "table-number-is-already-taken": "Table number is already taken", + "payment-method-not-found": "Payment method not found", + "payment-method-is-not-active": "Payment method is not active", + "payment-method-is-not-for-this-restaurant": "Payment method is not for this restaurant", + "payment-method-is-required": "Payment method is required" + }, + "order-dishes": { + "order-not-found": "Order not found", + "restaurant-not-assigned": "Please assign restaurant to this order", + "dish-not-found": "Dish not found", + "dish-not-assigned": "Dish not assigned to this restaurant", + "dish-already-in-order": "Dish already in order", + "order-dish-not-found": "Order dish not found", + "cant-update-not-pending-order-dish": "You can't update not pending order dish", + "cant-set-zero-quantity": "You can't set zero quantity. If you want to remove dish from order, use remove endpoint", + "is-removed": "You can't do anything with removed dish", + "already-removed": "Dish already removed", + "cant-force-not-cooking-dish": "You can't force ready not cooking dish", + "cant-update-cooking-dish": "You can't update cooking dish", + "cant-update-ready-dish": "You can't update ready dish", + "cant-force-ready-dish": "You can't force ready dish", + "not-enough-rights-to-make-returnment": "You don't have rights to make returnment", + "cant-make-returnment-for-not-completed-or-ready-dish": "You can't make returnment for not completed or ready dish", + "cant-make-returnment-for-more-than-added": "You can't make returnment for more than added", + "cant-make-returnment-for-dish-with-zero-quantity": "You can't make returnment for dish with zero quantity" + }, + "order-dish-modifiers": { + "some-dish-modifiers-not-found": "Some dish modifiers not found", + "some-dish-modifiers-not-assigned-to-restaurant": "Some dish modifiers not assigned to restaurant" + }, + "order-actions": { + "no-dishes-is-pending": "No dishes are pending", + "cant-create-precheck": "You can't create precheck for this order right now" + }, + "payment-methods": { + "secret-id-and-secret-key-are-required": "Secret id and secret key are required", + "restaurant-not-found": "Restaurant not found", + "not-enough-rights": "You don't have enough rights to do this" + }, + "discounts": { + "you-should-provide-at-least-one-restaurant-id": "You should provide at least one restaurant id", + "you-should-provide-at-least-one-menu": "You should provide at least one menu", + "you-provided-restaurant-id-that-you-dont-own": "You provided restaurant id that you don't own", + "all-menus-should-have-at-least-one-category": "All menus should have at least one category", + "all-menus-should-have-at-least-one-restaurant": "All menus should have at least one restaurant" + }, + "dishes-menus": { + "dish-menu-not-found": "Dish menu not found", + "owner-id-is-required": "Owner id is required", + "not-enough-rights": "You don't have enough rights to do this", + "some-restaurant-are-not-owned-by-the-owner": "Some restaurants are not owned by the owner", + "unable-to-remove-menu": "Unable to remove menu", + "menu-has-restaurants": "Menu has restaurants" + }, + "order-menu": { + "order-doesnt-have-restaurant": "Order doesn't have restaurant" + }, + "workshifts": { + "restaurant-not-available": "Restaurant not available", + "not-enough-rights": "You don't have enough rights to do this", + "close-previous-workshift": "You can't create new workshift because previous one is still opened", + "workshift-already-closed": "Workshift already closed" + }, + "workshift-payments": { + "not-enough-rights": "You don't have enough rights to do this", + "workshift-not-found": "Workshift not found", + "workshift-already-closed": "Workshift already closed", + "payment-category-not-found": "Payment category not found", + "payment-not-found": "Payment not found", + "payment-already-removed": "Payment already removed" + }, + "string-value-pipe": { + "invalid-value": "Invalid value", + "empty-array": "Array can't be empty" + } +} diff --git a/src/i18n/messages/en/validation.json b/src/i18n/messages/en/validation.json new file mode 100644 index 0000000..5ed9ed3 --- /dev/null +++ b/src/i18n/messages/en/validation.json @@ -0,0 +1,28 @@ +{ + "validators": { + "isString": "Must be a string", + "isNumber": "Must be a number", + "isBoolean": "Must be a boolean", + "isArray": "Must be an array", + "isObject": "Must be an object", + "isDate": "Must be a valid date", + "isEmail": "Must be a valid email address", + "isUUID": "Must be a valid UUID", + "isEnum": "Must be one of the allowed values", + "isNotEmpty": "Must not be empty", + "minLength": "Must be at least {constraints.0} characters long", + "maxLength": "Must be at most {constraints.0} characters long", + "min": "Must be greater than or equal to {constraints.0}", + "max": "Must be less than or equal to {constraints.0}", + "isPhoneNumber": "Must be a valid phone number", + "isTimeFormat": "Must be in HH:MM format (24-hour)", + "isStrongPassword": "Must be a strong password", + "isLatitude": "Must be a valid latitude", + "isInt": "Must be an integer", + "isDecimal": "Must be a decimal number", + "matches": "Invalid format" + }, + "time": { + "invalid_format": "Time must be in HH:MM format (e.g. 14:30)" + } +} diff --git a/src/i18n/messages/et/errors.json b/src/i18n/messages/et/errors.json new file mode 100644 index 0000000..2d8584e --- /dev/null +++ b/src/i18n/messages/et/errors.json @@ -0,0 +1,65 @@ +{ + "BAD_REQUEST": "Vigane päring", + "FORBIDDEN": "Keelatud", + "NOT_FOUND": "Ei leitud", + "UNAUTHORIZED": "Autoriseerimata", + "CONFLICT": "Konflikt", + "SERVER_ERROR": "Serveri viga", + "common": { + "atleast-one-field-should-be-provided": "Vähemalt üks väli peab olema täidetud", + "id-must-be-a-string-and-provided": "Id peab olema sõne ja peab olema esitatud", + "invalid-phone-number": "Vigane telefoninumber", + "invalid-phone-number-format": "Vigane telefoninumbri formaat", + "body-should-be-an-object": "Sisu peab olema objekt", + "validation-error": "Valideerimise viga", + "forbidden-access-to-resource": "Teil pole õigust sellele ressursile ligi pääseda", + "invalid-sort-by-field": "Vigane sorteerimisväli", + "invalid-sort-order-field": "Vigane sorteerimise järjekord", + "invalid-pagination-size": "Vigane lehekülje suurus", + "invalid-pagination-page": "Vigane lehekülje number", + "invalid-pagination-params": "Vigased lehekülgede parameetrid", + "invalid-filters-format": "Vigane filtrite formaat: peab olema JSON sõne või massiiv", + "invalid-filter-condition": "Vigane filtri tingimus", + "invalid-filter-format": "Vigane filtri formaat: igal filtril peab olema väli, väärtus ja tingimus" + }, + "auth": { + "invalid-credentials": "Vigased sisselogimisandmed" + }, + "workers": { + "with-this-id-doesnt-exist": "Sellise id-ga töötajat ei eksisteeri", + "cant-create-system-admin": "Te ei saa luua süsteemi administraatorit", + "not-enough-rights-to-create-worker-with-this-role": "Teil pole õigust luua selle rolliga töötajat", + "role": { + "cant-assign-restaurant-to-this-role": "Te ei saa sellele rollile restorani määrata" + }, + "failed-to-create-worker": "Töötaja loomine ebaõnnestus", + "worker-with-this-login-already-exists": "Sellise kasutajanimega töötaja on juba olemas" + }, + "restaurants": { + "provided-timezone-cant-be-set": "Määratud ajavööndit ei saa seadistada", + "with-this-id-doesnt-exist": "Sellise id-ga restorani ei eksisteeri", + "with-provided-id-doesnt-exist": "Määratud id-ga restorani ei eksisteeri" + }, + "restaurant-hours": { + "with-this-id-doesnt-exist": "Sellise id-ga restorani lahtiolekuaegu ei eksisteeri" + }, + "restaurant-workshops": { + "with-this-id-doesnt-exist": "Sellise id-ga restorani töökoda ei eksisteeri" + }, + "guests": { + "failed-to-create-guest": "Külalise loomine ebaõnnestus", + "with-this-id-doesnt-exist": "Sellise id-ga külalist ei eksisteeri" + }, + "dishes": { + "failed-to-create-dish": "Roa loomine ebaõnnestus", + "with-this-id-doesnt-exist": "Sellise id-ga rooga ei eksisteeri" + }, + "dish-categories": { + "failed-to-create-dish-category": "Roogade kategooria loomine ebaõnnestus", + "with-this-id-doesnt-exist": "Sellise id-ga roogade kategooriat ei eksisteeri" + }, + "orders": { + "table-number-is-required": "Nõutav on number", + "phone-number-is-required": "Nõutav on telefoninumber" + } +} diff --git a/src/i18n/messages/et/validation.json b/src/i18n/messages/et/validation.json new file mode 100644 index 0000000..a2a3022 --- /dev/null +++ b/src/i18n/messages/et/validation.json @@ -0,0 +1,24 @@ +{ + "validators": { + "isString": "Peab olema tekst", + "isNumber": "Peab olema number", + "isBoolean": "Peab olema tõene või väär", + "isArray": "Peab olema massiiv", + "isObject": "Peab olema objekt", + "isDate": "Peab olema kehtiv kuupäev", + "isEmail": "Peab olema kehtiv e-posti aadress", + "isUUID": "Peab olema kehtiv UUID", + "isEnum": "Peab olema üks lubatud väärtustest", + "isNotEmpty": "Ei tohi olla tühi", + "minLength": "Peab olema vähemalt {constraints.0} tähemärki pikk", + "maxLength": "Peab olema maksimaalselt {constraints.0} tähemärki pikk", + "min": "Peab olema suurem või võrdne kui {constraints.0}", + "max": "Peab olema väiksem või võrdne kui {constraints.0}", + "isPhoneNumber": "Peab olema kehtiv telefoninumber", + "isTimeFormat": "Peab olema HH:MM vormingus (24-tunni)", + "isStrongPassword": "Peab olema tugev parool", + "isLatitude": "Peab olema kehtiv laiuskraad", + "isInt": "Peab olema täisarv", + "isDecimal": "Peab olema kümnendarv" + } +} diff --git a/src/i18n/messages/ru/errors.json b/src/i18n/messages/ru/errors.json new file mode 100644 index 0000000..0b79a2a --- /dev/null +++ b/src/i18n/messages/ru/errors.json @@ -0,0 +1,83 @@ +{ + "BAD_REQUEST": "Некорректный запрос", + "FORBIDDEN": "Доступ запрещен", + "NOT_FOUND": "Не найдено", + "UNAUTHORIZED": "Не авторизован", + "CONFLICT": "Конфликт", + "SERVER_ERROR": "Ошибка сервера", + "common": { + "atleast-one-field-should-be-provided": "Необходимо указать хотя бы одно поле для обновления", + "id-must-be-a-string-and-provided": "Id должен быть строкой и должен быть указан", + "invalid-phone-number": "Неверный номер телефона", + "invalid-phone-number-format": "Неверный формат номера телефона", + "body-should-be-an-object": "Тело запроса должно быть объектом", + "validation-error": "Ошибка валидации", + "forbidden-access-to-resource": "У вас нет прав доступа к этому ресурсу", + "invalid-sort-by-field": "Неверное поле сортировки", + "invalid-sort-order-field": "Неверный порядок сортировки", + "invalid-pagination-size": "Неверный размер страницы", + "invalid-pagination-page": "Неверный номер страницы", + "invalid-pagination-params": "Неверные параметры пагинации", + "invalid-filters-format": "Неверный формат фильтров: должен быть JSON строкой или массивом", + "invalid-filter-condition": "Неверное условие фильтра", + "invalid-filter-format": "Неверный формат фильтра: каждый фильтр должен иметь поле, значение и условие", + "invalid-limit-value": "Неверное значение лимита", + "invalid-cursor-id": "Неверный идентификатор курсора", + "limit-too-big": "Лимит не может быть больше 1000" + }, + "auth": { + "invalid-credentials": "Неверные учетные данные" + }, + "workers": { + "with-this-id-doesnt-exist": "Работник с таким id не существует", + "cant-create-system-admin": "Вы не можете создать системного администратора", + "not-enough-rights-to-create-worker-with-this-role": "Вы не можете создать работника с этой ролью", + "role": { + "cant-assign-restaurant-to-this-role": "Вы не можете назначить ресторан этой роли" + }, + "failed-to-create-worker": "Не удалось создать работника", + "worker-with-this-login-already-exists": "Работник с таким логином уже существует" + }, + "restaurants": { + "provided-timezone-cant-be-set": "Указанный часовой пояс не может быть установлен", + "with-this-id-doesnt-exist": "Ресторан с таким id не существует", + "with-provided-id-doesnt-exist": "Ресторан с указанным id не существует" + }, + "restaurant-hours": { + "with-this-id-doesnt-exist": "Часы работы ресторана с таким id не существуют" + }, + "restaurant-workshops": { + "with-this-id-doesnt-exist": "Цех ресторана с таким id не существует" + }, + "guests": { + "failed-to-create-guest": "Не удалось создать гостя", + "with-this-id-doesnt-exist": "Гость с таким id не существует" + }, + "dishes": { + "failed-to-create-dish": "Не удалось создать блюдо", + "with-this-id-doesnt-exist": "Блюдо с таким id не существует" + }, + "dish-categories": { + "failed-to-create-dish-category": "Не удалось создать категорию блюд", + "with-this-id-doesnt-exist": "Категория блюд с таким id не существует" + }, + "orders": { + "table-number-is-required": "Необходимо указать номер стола", + "phone-number-is-required": "Необходимо указать номер телефона" + }, + "order-dishes": { + "order-not-found": "Заказ не найден", + "restaurant-not-assigned": "Пожалуйста, назначьте ресторан этому заказу", + "dish-not-found": "Блюдо не найдено", + "dish-not-assigned": "Блюдо не назначено этому ресторану", + "dish-already-in-order": "Блюдо уже в заказе", + "order-dish-not-found": "Блюдо заказа не найдено", + "cant-update-not-pending-order-dish": "Вы не можете обновить блюдо не в статусе добавлено", + "cant-set-zero-quantity": "Вы не можете установить нулевое количество. Если вы хотите удалить блюдо из заказа, используйте конечную точку удаления", + "is-removed": "Вы не можете ничего сделать с удаленным блюдом", + "already-removed": "Блюдо уже удалено" + }, + "string-value-pipe": { + "invalid-value": "Неверное значение" + } +} diff --git a/src/i18n/messages/ru/validation.json b/src/i18n/messages/ru/validation.json new file mode 100644 index 0000000..01e24e8 --- /dev/null +++ b/src/i18n/messages/ru/validation.json @@ -0,0 +1,24 @@ +{ + "validators": { + "isString": "Должно быть строкой", + "isNumber": "Должно быть числом", + "isBoolean": "Должно быть логическим значением", + "isArray": "Должно быть массивом", + "isObject": "Должно быть объектом", + "isDate": "Должно быть действительной датой", + "isEmail": "Должен быть действительный email адрес", + "isUUID": "Должен быть действительный UUID", + "isEnum": "Должно быть одним из разрешенных значений", + "isNotEmpty": "Не должно быть пустым", + "minLength": "Должно быть не менее {constraints.0} символов", + "maxLength": "Должно быть не более {constraints.0} символов", + "min": "Должно быть больше или равно {constraints.0}", + "max": "Должно быть меньше или равно {constraints.0}", + "isPhoneNumber": "Должен быть действительный номер телефона", + "isTimeFormat": "Должно быть в формате ЧЧ:ММ (24-часовой формат)", + "isStrongPassword": "Должно быть сильным паролем", + "isLatitude": "Должно быть действительной широтой", + "isInt": "Должно быть целым числом", + "isDecimal": "Должно быть десятичным числом" + } +} diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts new file mode 100644 index 0000000..3e03777 --- /dev/null +++ b/src/i18n/validators/index.ts @@ -0,0 +1,177 @@ +import { applyDecorators } from "@nestjs/common"; +// eslint-disable-next-line no-restricted-imports +import { + IsArray as _IsArray, + IsBoolean as _IsBoolean, + IsDate as _IsDate, + IsDecimal as _IsDecimal, + IsEnum as _IsEnum, + IsInt as _IsInt, + IsISO8601 as _IsISO8601, + IsLatitude as _IsLatitude, + IsNotEmpty as _IsNotEmpty, + IsNumber as _IsNumber, + IsObject as _IsObject, + IsOptional as _IsOptional, + IsString as _IsString, + IsStrongPassword as _IsStrongPassword, + IsUUID as _IsUUID, + Matches as _Matches, + Max as _Max, + MaxLength as _MaxLength, + Min as _Min, + MinLength as _MinLength, + IsNumberOptions, + ValidationOptions, +} from "class-validator"; +import { i18nValidationMessage } from "nestjs-i18n"; +import { DecimalLocale } from "validator"; + +// eslint-disable-next-line no-restricted-imports +export { + validate, + ValidationError, + registerDecorator, + ValidationArguments, + ValidateNested, +} from "class-validator"; +export { ValidationOptions }; + +// Helper function to merge validation options with i18n message +const mergeI18nValidation = ( + key: string, + validationOptions?: ValidationOptions, +): ValidationOptions => ({ + message: i18nValidationMessage(`validation.validators.${key}`), + ...validationOptions, +}); + +export const IsNotEmpty = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsNotEmpty(mergeI18nValidation("isNotEmpty", validationOptions)), + ); + +export const IsString = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsString(mergeI18nValidation("isString", validationOptions)), + ); + +export const IsBoolean = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsBoolean(mergeI18nValidation("isBoolean", validationOptions)), + ); + +export const IsObject = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsObject(mergeI18nValidation("isObject", validationOptions)), + ); + +export const IsUUID = ( + version?: "3" | "4" | "5" | "all", + validationOptions?: ValidationOptions, +) => + applyDecorators( + _IsUUID(version ?? "4", mergeI18nValidation("isUUID", validationOptions)), + ); + +export const IsEnum = (entity: object, validationOptions?: ValidationOptions) => + applyDecorators( + _IsEnum(entity, mergeI18nValidation("isEnum", validationOptions)), + ); + +export const IsISO8601 = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsISO8601({}, mergeI18nValidation("isDate", validationOptions)), + ); + +export const MinLength = (min: number, validationOptions?: ValidationOptions) => + applyDecorators( + _MinLength(min, mergeI18nValidation("minLength", validationOptions)), + ); + +export const MaxLength = (max: number, validationOptions?: ValidationOptions) => + applyDecorators( + _MaxLength(max, mergeI18nValidation("maxLength", validationOptions)), + ); + +export const IsNumber = ( + options: IsNumberOptions = {}, + validationOptions?: ValidationOptions, +) => + applyDecorators( + _IsNumber(options, mergeI18nValidation("isNumber", validationOptions)), + ); + +export const IsArray = (validationOptions?: ValidationOptions) => + applyDecorators(_IsArray(mergeI18nValidation("isArray", validationOptions))); + +// Re-export IsOptional as is since it doesn't have a message +export const IsOptional = _IsOptional; + +export const IsLatitude = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsLatitude(mergeI18nValidation("isLatitude", validationOptions)), + ); + +export const IsDate = (validationOptions?: ValidationOptions) => + applyDecorators(_IsDate(mergeI18nValidation("isDate", validationOptions))); + +export const IsStrongPassword = ( + options = { + minLength: 8, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 0, + }, + validationOptions?: ValidationOptions, +) => + applyDecorators( + _IsStrongPassword( + options, + mergeI18nValidation("isStrongPassword", validationOptions), + ), + ); + +export const IsInt = (validationOptions?: ValidationOptions) => + applyDecorators(_IsInt(mergeI18nValidation("isInt", validationOptions))); + +export const IsDecimal = ( + options: { + /** + * @default false + */ + force_decimal?: boolean | undefined; + /** + * `decimal_digits` is given as a range like `'1,3'`, + * a specific value like `'3'` or min like `'1,'` + * + * @default '1,' + */ + decimal_digits?: string | undefined; + /** + * DecimalLocale + * + * @default 'en-US' + */ + locale?: DecimalLocale | undefined; + } = {}, + validationOptions?: ValidationOptions, +) => + applyDecorators( + _IsDecimal(options, mergeI18nValidation("isDecimal", validationOptions)), + ); + +export const Min = (min: number, validationOptions?: ValidationOptions) => + applyDecorators(_Min(min, mergeI18nValidation("min", validationOptions))); + +export const Max = (max: number, validationOptions?: ValidationOptions) => + applyDecorators(_Max(max, mergeI18nValidation("max", validationOptions))); + +export const Matches = ( + pattern: RegExp, + validationOptions?: ValidationOptions, +) => + applyDecorators( + _Matches(pattern, mergeI18nValidation("matches", validationOptions)), + ); diff --git a/src/main.ts b/src/main.ts index 1277f86..2419c34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,61 +1,62 @@ +import "dotenv/config"; import { configApp } from "@core/config/app"; +import env from "@core/env"; import { NestFactory } from "@nestjs/core"; +import { + FastifyAdapter, + NestFastifyApplication, +} from "@nestjs/platform-fastify"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; -import { workers } from "@postgress-db/schema"; -import { hash } from "argon2"; -import { drizzle } from "drizzle-orm/node-postgres"; import { patchNestJsSwagger } from "nestjs-zod"; -import { Pool } from "pg"; -import * as schema from "src/drizzle/schema"; +import { SwaggerTheme, SwaggerThemeNameEnum } from "swagger-themes"; import { AppModule } from "./app.module"; import { AUTH_COOKIES } from "./auth/auth.types"; -export const createUserIfDbEmpty = async () => { - const db = drizzle( - new Pool({ connectionString: process.env.POSTGRESQL_URL }), - { schema }, +async function bootstrap() { + const app = await NestFactory.create( + AppModule, + new FastifyAdapter(), ); - if ((await db.query.workers.findMany()).length === 0) { - await db.insert(workers).values({ - login: "admin", - passwordHash: await hash(process.env.INITIAL_ADMIN_PASSWORD), - role: schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, + await configApp(app); + + // Only setup Swagger in development + if (env.NODE_ENV !== "production") { + const config = new DocumentBuilder() + .setTitle("Toite API") + .setDescription("The API part of the Toite project") + .setVersion("1.0.0 (just started)") + .addCookieAuth(AUTH_COOKIES.token, { + type: "apiKey", + }) + .setContact("Yefrosynii", "https://www.yefro.dev/", "contact@yefro.dev") + .addTag("workers", "Get data about workers and manage them") + .addTag("auth", "Part of authentification for workers part of the system") + .addTag("restaurants", "Get data about restaurants and manage them") + .addTag("guests", "Get data about guests and manage them") + .build(); + + const document = SwaggerModule.createDocument(app, config); + const theme = new SwaggerTheme(); + const darkStyle = theme.getBuffer(SwaggerThemeNameEnum.DARK); + + SwaggerModule.setup("docs", app, document, { + customSiteTitle: "Toite API Docs", + customfavIcon: + "https://avatars.githubusercontent.com/u/157302718?s=200&v=4", + customCss: darkStyle, + swaggerOptions: {}, }); } -}; -async function bootstrap() { - const app = await NestFactory.create(AppModule); - - configApp(app); - - const config = new DocumentBuilder() - .setTitle("Toite API") - .setDescription("The API part of the Toite project") - .setVersion("1.0.0 (just started)") - .addCookieAuth(AUTH_COOKIES.token, { - type: "apiKey", - }) - .setContact("Yefrosynii", "https://www.yefro.dev/", "contact@yefro.dev") - .addTag("workers", "Get data about workers and manage them") - .addTag("auth", "Part of authentification for workers part of the system") - .addTag("restaurants", "Get data about restaurants and manage them") - .build(); - - const document = SwaggerModule.createDocument(app, config); - - SwaggerModule.setup("docs", app, document, { - customSiteTitle: "Toite API Docs", - customfavIcon: - "https://avatars.githubusercontent.com/u/157302718?s=200&v=4", - swaggerOptions: {}, - }); - - await app.listen(3000); + await app.listen(env.PORT, "0.0.0.0"); + console.log("Run on port", env.PORT); +} + +// Only patch Swagger in development +if (env.NODE_ENV !== "production") { + patchNestJsSwagger(); } -createUserIfDbEmpty(); -patchNestJsSwagger(); bootstrap(); diff --git a/src/orders/@/dtos/add-order-dish.dto.ts b/src/orders/@/dtos/add-order-dish.dto.ts new file mode 100644 index 0000000..a251db0 --- /dev/null +++ b/src/orders/@/dtos/add-order-dish.dto.ts @@ -0,0 +1,22 @@ +import { IsNumber, IsUUID, Min } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class AddOrderDishDto { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Dish id", + type: String, + }) + dishId: string; + + @Expose() + @IsNumber() + @Min(1) + @ApiProperty({ + description: "Quantity", + type: Number, + }) + quantity: number; +} diff --git a/src/orders/@/dtos/create-order-dish-returnment.dto.ts b/src/orders/@/dtos/create-order-dish-returnment.dto.ts new file mode 100644 index 0000000..ace3b25 --- /dev/null +++ b/src/orders/@/dtos/create-order-dish-returnment.dto.ts @@ -0,0 +1,7 @@ +import { PickType } from "@nestjs/swagger"; +import { OrderDishReturnmentEntity } from "src/orders/@/entities/order-dish-returnment"; + +export class CreateOrderDishReturnmentDto extends PickType( + OrderDishReturnmentEntity, + ["quantity", "reason"], +) {} diff --git a/src/orders/@/dtos/create-order.dto.ts b/src/orders/@/dtos/create-order.dto.ts new file mode 100644 index 0000000..18397c1 --- /dev/null +++ b/src/orders/@/dtos/create-order.dto.ts @@ -0,0 +1,18 @@ +import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +export class CreateOrderDto extends IntersectionType( + PickType(OrderEntity, ["type"]), + PartialType( + PickType(OrderEntity, [ + "tableNumber", + "restaurantId", + "note", + "guestName", + "guestPhone", + "guestsAmount", + "delayedTo", + "paymentMethodId", + ]), + ), +) {} diff --git a/src/orders/@/dtos/kitchen-order-dish.dto.ts b/src/orders/@/dtos/kitchen-order-dish.dto.ts new file mode 100644 index 0000000..ef937ae --- /dev/null +++ b/src/orders/@/dtos/kitchen-order-dish.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { + OrderDishStatusEnum, + orderDishStatusEnum, +} from "@postgress-db/schema/order-dishes"; +import { Expose } from "class-transformer"; + +export class KitchenOrderDishDto { + @Expose() + @ApiProperty() + id: string; + + @Expose() + @ApiProperty() + name: string; + + @Expose() + @ApiProperty({ enum: orderDishStatusEnum.enumValues }) + status: OrderDishStatusEnum; + + @Expose() + @ApiProperty() + quantity: number; + + @Expose() + @ApiProperty() + cookingTimeInMin: number; +} diff --git a/src/orders/@/dtos/orders-paginated.dto.ts b/src/orders/@/dtos/orders-paginated.dto.ts new file mode 100644 index 0000000..2757128 --- /dev/null +++ b/src/orders/@/dtos/orders-paginated.dto.ts @@ -0,0 +1,15 @@ +import { PaginationResponseDto } from "@core/dto/pagination-response.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; + +import { OrderEntity } from "../entities/order.entity"; + +export class OrdersPaginatedDto extends PaginationResponseDto { + @Expose() + @ApiProperty({ + description: "Array of orders", + type: [OrderEntity], + }) + @Type(() => OrderEntity) + data: OrderEntity[]; +} diff --git a/src/orders/@/dtos/put-order-dish-modifiers.ts b/src/orders/@/dtos/put-order-dish-modifiers.ts new file mode 100644 index 0000000..39d65a0 --- /dev/null +++ b/src/orders/@/dtos/put-order-dish-modifiers.ts @@ -0,0 +1,14 @@ +import { IsArray, IsString } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class PutOrderDishModifiersDto { + @Expose() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ + description: "The IDs of the dish modifiers to add to the order dish", + example: ["123e4567-e89b-12d3-a456-426614174000"], + }) + dishModifierIds: string[]; +} diff --git a/src/orders/@/dtos/update-order-dish.dto.ts b/src/orders/@/dtos/update-order-dish.dto.ts new file mode 100644 index 0000000..edc3c9b --- /dev/null +++ b/src/orders/@/dtos/update-order-dish.dto.ts @@ -0,0 +1,14 @@ +import { IsNumber, Min } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class UpdateOrderDishDto { + @Expose() + @IsNumber() + @Min(1) + @ApiProperty({ + description: "Quantity", + type: Number, + }) + quantity: number; +} diff --git a/src/orders/@/dtos/update-order.dto.ts b/src/orders/@/dtos/update-order.dto.ts new file mode 100644 index 0000000..04ba7dd --- /dev/null +++ b/src/orders/@/dtos/update-order.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; + +export class UpdateOrderDto extends PartialType(CreateOrderDto) {} diff --git a/src/orders/@/entities/order-available-actions.entity.ts b/src/orders/@/entities/order-available-actions.entity.ts new file mode 100644 index 0000000..16357a2 --- /dev/null +++ b/src/orders/@/entities/order-available-actions.entity.ts @@ -0,0 +1,29 @@ +import { IsBoolean } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class OrderAvailableActionsEntity { + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Is precheck printing available", + example: true, + }) + canPrecheck: boolean; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Is send to kitchen available", + example: true, + }) + canSendToKitchen: boolean; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Is calculate order available", + example: true, + }) + canCalculate: boolean; +} diff --git a/src/orders/@/entities/order-dish-modifier.entity.ts b/src/orders/@/entities/order-dish-modifier.entity.ts new file mode 100644 index 0000000..fdfbe36 --- /dev/null +++ b/src/orders/@/entities/order-dish-modifier.entity.ts @@ -0,0 +1,21 @@ +import { IsString, IsUUID } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class OrderDishModifierEntity { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the order dish modifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the order dish modifier", + example: "Extra Cheese", + }) + name: string; +} diff --git a/src/orders/@/entities/order-dish-returnment.ts b/src/orders/@/entities/order-dish-returnment.ts new file mode 100644 index 0000000..4575033 --- /dev/null +++ b/src/orders/@/entities/order-dish-returnment.ts @@ -0,0 +1,80 @@ +import { + IsBoolean, + IsDate, + IsInt, + IsString, + IsUUID, + Min, + MinLength, +} from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IOrderDishReturnment } from "@postgress-db/schema/order-dishes"; +import { Expose } from "class-transformer"; + +export class OrderDishReturnmentEntity implements IOrderDishReturnment { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the returnment", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Order dish identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + orderDishId: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Worker identifier who processed the returnment", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workerId: string; + + @IsInt() + @Min(1) + @Expose() + @ApiProperty({ + description: "Quantity of dishes returned", + example: 1, + }) + quantity: number; + + @IsString() + @MinLength(3) + @Expose() + @ApiProperty({ + description: "Reason for the returnment", + example: "Food was cold", + }) + reason: string; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the returnment was done after precheck", + example: false, + }) + isDoneAfterPrecheck: boolean; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Date when returnment was created", + example: new Date(), + }) + createdAt: Date; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Date when returnment was last updated", + example: new Date(), + }) + updatedAt: Date; +} diff --git a/src/orders/@/entities/order-dish-snapshot.entity.ts b/src/orders/@/entities/order-dish-snapshot.entity.ts new file mode 100644 index 0000000..2c240af --- /dev/null +++ b/src/orders/@/entities/order-dish-snapshot.entity.ts @@ -0,0 +1,3 @@ +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; + +export class OrderDishSnapshotEntity extends OrderDishEntity {} diff --git a/src/orders/@/entities/order-dish.entity.ts b/src/orders/@/entities/order-dish.entity.ts new file mode 100644 index 0000000..3a1f67f --- /dev/null +++ b/src/orders/@/entities/order-dish.entity.ts @@ -0,0 +1,213 @@ +import { + IsArray, + IsBoolean, + IsDate, + IsDecimal, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IOrderDish, + OrderDishStatusEnum, + ZodOrderDishStatusEnum, +} from "@postgress-db/schema/order-dishes"; +import { Expose, Type } from "class-transformer"; +import { OrderDishModifierEntity } from "src/orders/@/entities/order-dish-modifier.entity"; + +export class OrderDishEntity implements IOrderDish { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the order dish", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Order identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + orderId: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Dish identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + dishId: string; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Discount identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + discountId: string | null; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Surcharge identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + surchargeId: string | null; + + @IsString() + @Expose() + @ApiProperty({ + description: "Dish name", + example: "Caesar Salad", + }) + name: string; + + @IsEnum(ZodOrderDishStatusEnum.Enum) + @Expose() + @ApiProperty({ + description: "Order dish status", + enum: ZodOrderDishStatusEnum.Enum, + example: "pending", + }) + // status: typeof ZodOrderDishStatusEnum._type; + status: OrderDishStatusEnum; + + @Expose() + @IsArray() + @Type(() => OrderDishModifierEntity) + @ApiProperty({ + description: "Dish modifiers", + type: [OrderDishModifierEntity], + }) + modifiers: OrderDishModifierEntity[]; + + @IsInt() + @Expose() + @ApiProperty({ + description: "Quantity ordered", + example: 2, + }) + quantity: number; + + @IsInt() + @Expose() + @ApiProperty({ + description: "Quantity returned", + example: 0, + }) + quantityReturned: number; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Original price", + example: "15.99", + }) + price: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Discount percentage", + example: "10.00", + }) + discountPercent: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Discount amount", + example: "1.59", + }) + discountAmount: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Surcharge percentage", + example: "5.00", + }) + surchargePercent: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Surcharge amount", + example: "0.80", + }) + surchargeAmount: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Final price after discounts and surcharges", + example: "15.20", + }) + finalPrice: string; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is order dish removed", + example: false, + }) + isRemoved: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is additional order", + example: false, + }) + isAdditional: boolean; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Creation timestamp", + example: new Date(), + }) + createdAt: Date; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Last update timestamp", + example: new Date(), + }) + updatedAt: Date; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Date when dish was cooking", + example: null, + }) + cookingAt: Date | null; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Date when dish was ready", + example: null, + }) + readyAt: Date | null; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Removal timestamp", + example: null, + }) + removedAt: Date | null; +} diff --git a/src/orders/@/entities/order-history-paginated.entity.ts b/src/orders/@/entities/order-history-paginated.entity.ts new file mode 100644 index 0000000..13b3798 --- /dev/null +++ b/src/orders/@/entities/order-history-paginated.entity.ts @@ -0,0 +1,14 @@ +import { PaginationResponseDto } from "@core/dto/pagination-response.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { OrderHistoryEntity } from "src/orders/@/entities/order-history.entity"; + +export class OrderHistoryPaginatedEntity extends PaginationResponseDto { + @Expose() + @ApiProperty({ + description: "Array of order history records", + type: [OrderHistoryEntity], + }) + @Type(() => OrderHistoryEntity) + data: OrderHistoryEntity[]; +} diff --git a/src/orders/@/entities/order-history-record.entity.ts b/src/orders/@/entities/order-history-record.entity.ts new file mode 100644 index 0000000..cc3b67b --- /dev/null +++ b/src/orders/@/entities/order-history-record.entity.ts @@ -0,0 +1,66 @@ +import { IsEnum, IsISO8601, IsOptional, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { ZodOrderHistoryTypeEnum } from "@postgress-db/schema/order-enums"; +import { IOrderHistoryRecord } from "@postgress-db/schema/order-history"; +import { Expose, Type } from "class-transformer"; +import { WorkerEntity } from "src/workers/entities/worker.entity"; + +export class OrderHistoryRecordWorkerEntity extends PickType(WorkerEntity, [ + "id", + "name", + "role", +]) {} + +export class OrderHistoryRecordEntity implements IOrderHistoryRecord { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the order history record", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the order", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + orderId: string; + + @IsOptional() + @IsUUID() + @Expose() + @ApiPropertyOptional({ + description: "Unique identifier of the worker", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workerId: string | null; + + @Expose() + @IsOptional() + @Type(() => OrderHistoryRecordWorkerEntity) + @ApiPropertyOptional({ + description: "Worker data", + type: OrderHistoryRecordWorkerEntity, + }) + worker: OrderHistoryRecordWorkerEntity | null; + + @IsEnum(ZodOrderHistoryTypeEnum.Enum) + @Expose() + @ApiProperty({ + description: "Type of the order history record", + enum: ZodOrderHistoryTypeEnum.Enum, + example: ZodOrderHistoryTypeEnum.Enum.precheck, + examples: Object.values(ZodOrderHistoryTypeEnum.Enum), + }) + type: typeof ZodOrderHistoryTypeEnum._type; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when the order history record was created", + example: new Date("2021-08-01T00:00:00.000Z"), + }) + createdAt: Date; +} diff --git a/src/orders/@/entities/order-history.entity.ts b/src/orders/@/entities/order-history.entity.ts new file mode 100644 index 0000000..c1350da --- /dev/null +++ b/src/orders/@/entities/order-history.entity.ts @@ -0,0 +1,18 @@ +import { IsOptional } from "@i18n-class-validator"; +import { ApiPropertyOptional, OmitType } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { OrderHistoryRecordEntity } from "src/orders/@/entities/order-history-record.entity"; +import { OrderPrecheckEntity } from "src/orders/@/entities/order-precheck.entity"; + +export class OrderHistoryEntity extends OmitType(OrderHistoryRecordEntity, [ + "orderId", +]) { + @Expose() + @IsOptional() + @Type(() => OrderPrecheckEntity) + @ApiPropertyOptional({ + description: "Precheck data", + type: OrderPrecheckEntity, + }) + precheck: OrderPrecheckEntity | null; +} diff --git a/src/orders/@/entities/order-menu-dish.entity.ts b/src/orders/@/entities/order-menu-dish.entity.ts new file mode 100644 index 0000000..5f9a9bd --- /dev/null +++ b/src/orders/@/entities/order-menu-dish.entity.ts @@ -0,0 +1,50 @@ +import { IsBoolean, ValidateNested } from "@i18n-class-validator"; +import { + ApiProperty, + ApiPropertyOptional, + IntersectionType, + PickType, +} from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { DishEntity } from "src/dishes/@/entities/dish.entity"; +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +export class OrderMenuDishOrderDishEntity extends IntersectionType( + PickType(OrderDishEntity, [ + "price", + "quantity", + "modifiers", + "id", + "discountPercent", + "surchargePercent", + "finalPrice", + ]), + PickType(OrderEntity, ["currency"]), +) {} + +export class OrderMenuDishEntity extends IntersectionType( + PickType(DishEntity, [ + "id", + "name", + "images", + "amountPerItem", + "cookingTimeInMin", + ]), +) { + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Whether the dish is in the stop list", + }) + isInStopList: boolean; + + @Expose() + @ApiPropertyOptional({ + description: "Order dish", + type: OrderMenuDishOrderDishEntity, + }) + @Type(() => OrderMenuDishOrderDishEntity) + @ValidateNested() + orderDish: OrderMenuDishOrderDishEntity | null; +} diff --git a/src/orders/@/entities/order-menu-dishes-cursor.entity.ts b/src/orders/@/entities/order-menu-dishes-cursor.entity.ts new file mode 100644 index 0000000..2d8a639 --- /dev/null +++ b/src/orders/@/entities/order-menu-dishes-cursor.entity.ts @@ -0,0 +1,14 @@ +import { CursorResponseDto } from "@core/dto/cursor-response.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { OrderMenuDishEntity } from "src/orders/@/entities/order-menu-dish.entity"; + +export class OrderMenuDishesCursorEntity extends CursorResponseDto { + @Expose() + @ApiProperty({ + description: "Array of dishes", + type: [OrderMenuDishEntity], + }) + @Type(() => OrderMenuDishEntity) + data: OrderMenuDishEntity[]; +} diff --git a/src/orders/@/entities/order-precheck-position.entity.ts b/src/orders/@/entities/order-precheck-position.entity.ts new file mode 100644 index 0000000..8d99088 --- /dev/null +++ b/src/orders/@/entities/order-precheck-position.entity.ts @@ -0,0 +1,70 @@ +import { IsDecimal, IsInt, IsString, IsUUID } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IOrderPrecheckPosition } from "@postgress-db/schema/order-prechecks"; +import { Expose } from "class-transformer"; + +export class OrderPrecheckPositionEntity implements IOrderPrecheckPosition { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the precheck position", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Precheck identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + precheckId: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Position name", + example: "Burger", + }) + name: string; + + @IsInt() + @Expose() + @ApiProperty({ + description: "Quantity", + example: 2, + }) + quantity: number; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Price per unit", + example: "10.00", + }) + price: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Discount amount", + example: "1.00", + }) + discountAmount: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Surcharge amount", + example: "0.50", + }) + surchargeAmount: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Final price after discounts and surcharges", + example: "9.50", + }) + finalPrice: string; +} diff --git a/src/orders/@/entities/order-precheck.entity.ts b/src/orders/@/entities/order-precheck.entity.ts new file mode 100644 index 0000000..2e75dfe --- /dev/null +++ b/src/orders/@/entities/order-precheck.entity.ts @@ -0,0 +1,111 @@ +import { IsDate, IsEnum, IsString, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { ZodCurrency, ZodLocaleEnum } from "@postgress-db/schema/general"; +import { ZodOrderTypeEnum } from "@postgress-db/schema/order-enums"; +import { IOrderPrecheck } from "@postgress-db/schema/order-prechecks"; +import { Expose, Type } from "class-transformer"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { WorkerEntity } from "src/workers/entities/worker.entity"; + +import { OrderPrecheckPositionEntity } from "./order-precheck-position.entity"; + +export class OrderPrecheckWorkerEntity extends PickType(WorkerEntity, [ + "name", + "role", +]) {} + +export class OrderPrecheckIncludedOrderEntity extends PickType(OrderEntity, [ + "number", +]) {} + +export class OrderPrecheckEntity implements IOrderPrecheck { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the precheck", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Order identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + orderId: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Worker identifier who performed the precheck", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workerId: string; + + @IsEnum(ZodOrderTypeEnum.Enum) + @Expose() + @ApiProperty({ + description: "Order type", + enum: ZodOrderTypeEnum.Enum, + example: "hall", + }) + type: typeof ZodOrderTypeEnum._type; + + @IsString() + @Expose() + @ApiProperty({ + description: "Legal entity", + example: "Restaurant LLC", + }) + legalEntity: string; + + @IsEnum(ZodLocaleEnum.Enum) + @Expose() + @ApiProperty({ + description: "Locale", + enum: ZodLocaleEnum.Enum, + example: "en", + }) + locale: typeof ZodLocaleEnum._type; + + @IsEnum(ZodCurrency.Enum) + @Expose() + @ApiProperty({ + description: "Currency", + enum: ZodCurrency.Enum, + }) + currency: typeof ZodCurrency._type; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Creation timestamp", + example: new Date(), + }) + createdAt: Date; + + @Expose() + @ApiProperty({ + description: "Precheck positions", + type: [OrderPrecheckPositionEntity], + }) + @Type(() => OrderPrecheckPositionEntity) + positions: OrderPrecheckPositionEntity[]; + + @Expose() + @ApiProperty({ + description: "Precheck worker", + type: OrderPrecheckWorkerEntity, + }) + @Type(() => OrderPrecheckWorkerEntity) + worker: OrderPrecheckWorkerEntity; + + @Expose() + @ApiProperty({ + description: "Precheck order", + type: OrderPrecheckIncludedOrderEntity, + }) + @Type(() => OrderPrecheckIncludedOrderEntity) + order: OrderPrecheckIncludedOrderEntity; +} diff --git a/src/orders/@/entities/order-snapshot.entity.ts b/src/orders/@/entities/order-snapshot.entity.ts new file mode 100644 index 0000000..8e93b51 --- /dev/null +++ b/src/orders/@/entities/order-snapshot.entity.ts @@ -0,0 +1,7 @@ +import { OmitType } from "@nestjs/swagger"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +export class OrderSnapshotEntity extends OmitType(OrderEntity, [ + "orderDishes", + "restaurantName", +]) {} diff --git a/src/orders/@/entities/order.entity.ts b/src/orders/@/entities/order.entity.ts new file mode 100644 index 0000000..58fa128 --- /dev/null +++ b/src/orders/@/entities/order.entity.ts @@ -0,0 +1,300 @@ +import { IsPhoneNumber } from "@core/decorators/is-phone.decorator"; +import { + IsBoolean, + IsDate, + IsDecimal, + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Min, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ZodCurrency } from "@postgress-db/schema/general"; +import { + ZodOrderFromEnum, + ZodOrderStatusEnum, + ZodOrderTypeEnum, +} from "@postgress-db/schema/order-enums"; +import { IOrder } from "@postgress-db/schema/orders"; +import { Expose, Type } from "class-transformer"; +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; + +export class OrderEntity implements IOrder { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the order", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Guest identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + guestId: string | null; + + @IsUUID() + @IsOptional() + @ApiPropertyOptional({ + description: "Discounts guest identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + discountsGuestId: string | null; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Restaurant identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + restaurantId: string | null; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Payment method identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + paymentMethodId: string | null; + + @IsString() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Restaurant name", + example: "Downtown Restaurant", + }) + restaurantName: string | null; + + @IsString() + @Expose() + @ApiProperty({ + description: "Order number", + example: "1234", + }) + number: string; + + @IsString() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Table number", + example: "A12", + }) + tableNumber: string | null; + + @IsEnum(ZodOrderTypeEnum.Enum) + @Expose() + @ApiProperty({ + description: "Order type", + enum: ZodOrderTypeEnum.Enum, + example: "hall", + }) + type: typeof ZodOrderTypeEnum._type; + + @IsEnum(ZodOrderStatusEnum.Enum) + @Expose() + @ApiProperty({ + description: "Order status", + enum: ZodOrderStatusEnum.Enum, + example: "pending", + }) + status: typeof ZodOrderStatusEnum._type; + + @IsEnum(ZodCurrency.Enum) + @Expose() + @ApiProperty({ + description: "Currency", + enum: ZodCurrency.Enum, + example: "EUR", + }) + currency: typeof ZodCurrency._type; + + @IsEnum(ZodOrderFromEnum.Enum) + @Expose() + @ApiProperty({ + description: "Order from", + enum: ZodOrderFromEnum.Enum, + example: "hall", + }) + from: typeof ZodOrderFromEnum._type; + + @IsString() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Order note", + example: "Please prepare without onions", + }) + note: string | null; + + @IsString() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Guest name", + example: "John Doe", + }) + guestName: string | null; + + @IsOptional() + @IsPhoneNumber({ + isOptional: true, + }) + @Expose() + @ApiPropertyOptional({ + description: "Guest phone number", + example: "+372 5555 5555", + }) + guestPhone: string | null; + + @IsInt() + @Min(1) + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Number of guests", + example: 4, + }) + guestsAmount: number | null; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Subtotal amount", + example: "100.00", + }) + subtotal: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Discount amount", + example: "10.00", + }) + discountAmount: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Surcharge amount", + example: "5.00", + }) + surchargeAmount: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Bonus points used", + example: "0.00", + }) + bonusUsed: string; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Total amount", + example: "95.00", + }) + total: string; + + @IsBoolean() + @ApiProperty({ + description: "Is discounts applied", + example: false, + }) + applyDiscounts: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is order hidden for guest", + example: false, + }) + isHiddenForGuest: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is order removed", + example: false, + }) + isRemoved: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is order archived", + example: false, + }) + isArchived: boolean; + + @Expose() + @ApiProperty({ + description: "Order dishes", + type: [OrderDishEntity], + }) + @Type(() => OrderDishEntity) + orderDishes: OrderDishEntity[]; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Date when order was created", + example: new Date(), + }) + createdAt: Date; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Date when order was last updated", + example: new Date(), + }) + updatedAt: Date; + + @Expose() + @IsDate() + @IsOptional() + @ApiPropertyOptional({ + description: "Date when order was cooking", + example: null, + }) + cookingAt: Date | null; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Date when order was completed", + example: null, + }) + completedAt: Date | null; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Removal timestamp", + example: null, + }) + removedAt: Date | null; + + @IsISO8601() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Delayed to timestamp", + example: null, + }) + delayedTo: Date | null; +} diff --git a/src/orders/@/order-actions.controller.ts b/src/orders/@/order-actions.controller.ts new file mode 100644 index 0000000..0aee67e --- /dev/null +++ b/src/orders/@/order-actions.controller.ts @@ -0,0 +1,75 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { Get, Param, Post } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; +import { OrderPrecheckEntity } from "src/orders/@/entities/order-precheck.entity"; +import { OrderActionsService } from "src/orders/@/services/order-actions.service"; +import { OrderDiscountsService } from "src/orders/@/services/order-discounts.service"; + +@Controller("orders/:id/actions", { + tags: ["orders"], +}) +export class OrderActionsController { + constructor( + private readonly orderDiscountsService: OrderDiscountsService, + private readonly orderActionsService: OrderActionsService, + ) {} + + @Get("available") + @Serializable(OrderAvailableActionsEntity) + @ApiOperation({ summary: "Gets available actions for the order" }) + @ApiOkResponse({ + description: "Available actions for the order", + type: OrderAvailableActionsEntity, + }) + async getAvailableActions(@Param("id") id: string) { + return this.orderActionsService.getAvailableActions(id); + } + + @Post("send-to-kitchen") + @ApiOperation({ summary: "Sends the order to the kitchen" }) + @ApiOkResponse({ + description: "Order sent to the kitchen", + }) + async sendToKitchen( + @Param("id") orderId: string, + @Worker() worker: RequestWorker, + ) { + return this.orderActionsService.sendToKitchen(orderId, { + worker, + }); + } + + @Post("precheck") + @Serializable(OrderPrecheckEntity) + @ApiOperation({ summary: "Creates a precheck for the order" }) + @ApiOkResponse({ + description: "Precheck created and info for it returned", + type: OrderPrecheckEntity, + }) + async createPrecheck( + @Param("id") orderId: string, + @Worker() worker: RequestWorker, + ) { + return this.orderActionsService.createPrecheck(orderId, { + worker, + }); + } + + @Post("apply-discounts") + @ApiOperation({ summary: "Applies discounts to the order" }) + @ApiOkResponse({ + description: "Order discounts applied", + }) + async applyDiscounts( + @Param("id") orderId: string, + @Worker() worker: RequestWorker, + ) { + return this.orderDiscountsService.applyDiscounts(orderId, { + worker, + }); + } +} diff --git a/src/orders/@/order-dishes.controller.ts b/src/orders/@/order-dishes.controller.ts new file mode 100644 index 0000000..62bde63 --- /dev/null +++ b/src/orders/@/order-dishes.controller.ts @@ -0,0 +1,166 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { Body, Delete, Param, Patch, Post, Put } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; +import { CreateOrderDishReturnmentDto } from "src/orders/@/dtos/create-order-dish-returnment.dto"; +import { PutOrderDishModifiersDto } from "src/orders/@/dtos/put-order-dish-modifiers"; +import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { OrderActionsService } from "src/orders/@/services/order-actions.service"; +import { OrderDishesService } from "src/orders/@/services/order-dishes.service"; +import { OrdersService } from "src/orders/@/services/orders.service"; +import { KitchenerOrderActionsService } from "src/orders/kitchener/kitchener-order-actions.service"; + +@Controller("orders/:id/dishes", { + tags: ["orders"], +}) +export class OrderDishesController { + constructor( + private readonly ordersService: OrdersService, + private readonly orderDishesService: OrderDishesService, + private readonly kitchenerOrderActionsService: KitchenerOrderActionsService, + private readonly orderActionsService: OrderActionsService, + ) {} + + @EnableAuditLog() + @Post() + @Serializable(OrderEntity) + @ApiOperation({ summary: "Adds a dish to the order" }) + @ApiOkResponse({ + description: "Dish has been successfully added to the order", + type: OrderEntity, + }) + @ApiNotFoundResponse({ + description: "Order with this id doesn't exist", + }) + @ApiBadRequestResponse({ + description: "Dish with this id doesn't exist", + }) + async addDish( + @Param("id") orderId: string, + @Body() payload: AddOrderDishDto, + @Worker() worker: RequestWorker, + ) { + await this.orderDishesService.addToOrder(orderId, payload, { + workerId: worker.id, + }); + + return this.ordersService.findById(orderId); + } + + @EnableAuditLog() + @Patch(":orderDishId") + @Serializable(OrderEntity) + @ApiOperation({ summary: "Updates a dish in the order" }) + @ApiOkResponse({ + description: "Dish has been successfully updated in the order", + type: OrderEntity, + }) + @ApiNotFoundResponse({ + description: "Order with this id doesn't exist", + }) + @ApiBadRequestResponse() + async updateDish( + @Param("id") orderId: string, + @Param("orderDishId") orderDishId: string, + @Body() payload: UpdateOrderDishDto, + @Worker() worker: RequestWorker, + ) { + await this.orderDishesService.update(orderDishId, payload, { + workerId: worker.id, + }); + + return this.ordersService.findById(orderId); + } + + @EnableAuditLog() + @Delete(":orderDishId") + @ApiOperation({ summary: "Removes a dish from the order" }) + @ApiOkResponse({ + description: "Dish has been successfully removed from the order", + type: OrderEntity, + }) + @ApiNotFoundResponse({ + description: "Order with this id doesn't exist", + }) + @ApiBadRequestResponse() + async removeDish( + @Param("id") orderId: string, + @Param("orderDishId") orderDishId: string, + @Worker() worker: RequestWorker, + ) { + await this.orderDishesService.remove(orderDishId, { + workerId: worker.id, + }); + + return this.ordersService.findById(orderId); + } + + @EnableAuditLog() + @Post(":orderDishId/force-ready") + @ApiOperation({ summary: "Forces a dish to be ready" }) + @ApiOkResponse({ + description: "Dish has been successfully marked as ready", + }) + async forceReadyDish( + @Param("id") orderId: string, + @Param("orderDishId") orderDishId: string, + @Worker() worker: RequestWorker, + ) { + await this.kitchenerOrderActionsService.markDishAsReady(orderDishId, { + worker, + }); + + return this.ordersService.findById(orderId); + } + + @EnableAuditLog() + @Post(":orderDishId/return") + @ApiOperation({ + summary: "Makes an returnment of a dish", + }) + @ApiOkResponse({ + description: "Dish has been successfully returned", + }) + async returnDish( + @Param("id") orderId: string, + @Param("orderDishId") orderDishId: string, + @Body() payload: CreateOrderDishReturnmentDto, + @Worker() worker: RequestWorker, + ) { + await this.orderActionsService.makeOrderDishReturnment( + orderDishId, + payload, + { + worker, + }, + ); + + return this.ordersService.findById(orderId); + } + + @EnableAuditLog() + @Put(":orderDishId/modifiers") + @ApiOperation({ summary: "Updates the modifiers for a dish in the order" }) + @ApiOkResponse({ + description: "Dish modifiers have been successfully updated", + }) + async updateDishModifiers( + @Param("id") orderId: string, + @Param("orderDishId") orderDishId: string, + @Body() payload: PutOrderDishModifiersDto, + ) { + await this.orderDishesService.updateDishModifiers(orderDishId, payload); + + return true; + } +} diff --git a/src/orders/@/order-history.controller.ts b/src/orders/@/order-history.controller.ts new file mode 100644 index 0000000..de25617 --- /dev/null +++ b/src/orders/@/order-history.controller.ts @@ -0,0 +1,57 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { + IPagination, + PaginationParams, +} from "@core/decorators/pagination.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Get, Param } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { CacheRequest } from "src/@base/cache/cache.decorator"; +import { OrderHistoryPaginatedEntity } from "src/orders/@/entities/order-history-paginated.entity"; +import { OrderHistoryService } from "src/orders/@/services/order-history.service"; + +@Controller("orders/:orderId/history", { + tags: ["orders"], +}) +export class OrderHistoryController { + constructor(private readonly orderHistoryService: OrderHistoryService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Serializable(OrderHistoryPaginatedEntity) + @Get() + @CacheRequest({ ttl: 5 }) + @ApiOperation({ + summary: "Finds all history records for an order", + description: "Returns a paginated list of order history records", + }) + @ApiOkResponse({ + type: OrderHistoryPaginatedEntity, + }) + public async findMany( + @PaginationParams({ + default: { + limit: 100, + }, + }) + pagination: IPagination, + @Param("orderId") orderId: string, + ): Promise { + const total = await this.orderHistoryService.getTotalCount(orderId); + const data = await this.orderHistoryService.findMany(orderId, { + limit: pagination.limit, + offset: pagination.offset, + }); + + return { + data, + ...pagination, + meta: { + page: pagination.page, + size: pagination.limit, + offset: pagination.offset, + total, + }, + }; + } +} diff --git a/src/orders/@/order-menu.controller.ts b/src/orders/@/order-menu.controller.ts new file mode 100644 index 0000000..b13bf57 --- /dev/null +++ b/src/orders/@/order-menu.controller.ts @@ -0,0 +1,73 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { CursorParams, ICursor } from "@core/decorators/cursor.decorator"; +import SearchQuery from "@core/decorators/search.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { StringValuePipe } from "@core/pipes/string.pipe"; +import { Get, Param, Query } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation, ApiQuery } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { DishCategoryEntity } from "src/dish-categories/entities/dish-category.entity"; +import { OrderMenuDishesCursorEntity } from "src/orders/@/entities/order-menu-dishes-cursor.entity"; +import { OrderMenuService } from "src/orders/@/services/order-menu.service"; + +@Controller("/orders/:orderId/menu", { + tags: ["orders"], +}) +export class OrderMenuController { + constructor(private readonly orderMenuService: OrderMenuService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Serializable(DishCategoryEntity) + @Get("categories") + @ApiOperation({ + summary: + "Gets list of dish categories that are available for the order dishes", + }) + @ApiOkResponse({ + description: "List of dish categories", + type: DishCategoryEntity, + }) + async getDishCategories( + @Param("orderId") orderId: string, + ): Promise { + return this.orderMenuService.findDishCategories(orderId); + } + + @EnableAuditLog({ onlyErrors: true }) + @Serializable(OrderMenuDishesCursorEntity) + @Get("dishes") + @ApiOperation({ + summary: "Gets dishes that can be added to the order", + }) + @ApiOkResponse({ + description: "Dishes that can be added to the order", + type: OrderMenuDishesCursorEntity, + }) + @ApiQuery({ + name: "categoryId", + type: String, + required: false, + description: "Filter dishes by category id", + }) + async getDishes( + @Param("orderId") orderId: string, + @CursorParams() cursor: ICursor, + @SearchQuery() search?: string, + @Query("categoryId", new StringValuePipe()) dishCategoryId?: string, + ): Promise { + const data = await this.orderMenuService.getDishes(orderId, { + cursor, + search, + dishCategoryId, + }); + + return { + meta: { + cursorId: cursor.cursorId ?? null, + limit: cursor.limit, + total: data.length, + }, + data, + }; + } +} diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts new file mode 100644 index 0000000..004699a --- /dev/null +++ b/src/orders/@/orders.controller.ts @@ -0,0 +1,87 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Body, Get, Param, Patch, Post } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; +import { UpdateOrderDto } from "src/orders/@/dtos/update-order.dto"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { OrderDishesService } from "src/orders/@/services/order-dishes.service"; +import { OrdersService } from "src/orders/@/services/orders.service"; + +@Controller("orders") +export class OrdersController { + constructor( + private readonly ordersService: OrdersService, + private readonly orderDishesService: OrderDishesService, + ) {} + + @EnableAuditLog() + @Post() + @Serializable(OrderEntity) + @ApiOperation({ summary: "Creates a new order" }) + @ApiCreatedResponse({ + description: "Order has been successfully created", + type: OrderEntity, + }) + async create(@Body() dto: CreateOrderDto, @Worker() worker: RequestWorker) { + return this.ordersService.create(dto, { + workerId: worker.id, + }); + } + + @EnableAuditLog({ onlyErrors: true }) + @Get(":id") + @Serializable(OrderEntity) + @ApiOperation({ summary: "Gets order by id" }) + @ApiOkResponse({ + description: "Order has been successfully fetched", + type: OrderEntity, + }) + @ApiNotFoundResponse({ + description: "Order with this id doesn't exist", + }) + @ApiBadRequestResponse({ + description: "Id must be a string and provided", + }) + async findOne(@Param("id") id?: string): Promise { + if (!id) { + throw new BadRequestException( + "errors.common.id-must-be-a-string-and-provided", + ); + } + + return await this.ordersService.findById(id); + } + + @EnableAuditLog() + @Patch(":id") + @Serializable(OrderEntity) + @ApiOperation({ summary: "Updates an order" }) + @ApiOkResponse({ + description: "Order has been successfully updated", + type: OrderEntity, + }) + @ApiNotFoundResponse({ + description: "Order with this id doesn't exist", + }) + @ApiBadRequestResponse() + async update( + @Param("id") id: string, + @Body() dto: UpdateOrderDto, + @Worker() worker: RequestWorker, + ) { + return this.ordersService.update(id, dto, { + workerId: worker.id, + }); + } +} diff --git a/src/orders/@/repositories/order-dishes.repository.ts b/src/orders/@/repositories/order-dishes.repository.ts new file mode 100644 index 0000000..a2da951 --- /dev/null +++ b/src/orders/@/repositories/order-dishes.repository.ts @@ -0,0 +1,294 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { DrizzleTransaction, Schema } from "@postgress-db/drizzle.module"; +import { IOrderDish, orderDishes } from "@postgress-db/schema/order-dishes"; +import { plainToClass } from "class-transformer"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { SnapshotsProducer } from "src/@base/snapshots/snapshots.producer"; +import { PG_CONNECTION } from "src/constants"; +import { OrderDishSnapshotEntity } from "src/orders/@/entities/order-dish-snapshot.entity"; +import { OrderUpdatersService } from "src/orders/@/services/order-updaters.service"; + +export type OrderDishUpdatePayload = { + orderDishId: string; +} & Partial; + +@Injectable() +export class OrderDishesRepository { + private readonly logger = new Logger(OrderDishesRepository.name); + + constructor( + // Postgres connection + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + // Services + private readonly orderUpdatersService: OrderUpdatersService, + private readonly snapshotsProducer: SnapshotsProducer, + ) {} + + /** + * Proceeds data for snapshot. Excludes extraneous values + * @param result + * @returns + */ + private _proceedForSnapshot(result: typeof orderDishes.$inferSelect) { + return plainToClass(OrderDishSnapshotEntity, result, { + excludeExtraneousValues: true, + }); + } + + /** + * Validates payload + * @param payload + */ + private _validatePayload( + payload: Omit, + ) { + if ( + (payload.discountPercent || + payload.surchargePercent || + payload.discountAmount || + payload.surchargeAmount) && + !payload.price + ) { + this.logger.error( + `Discount/surcharge is set but we don't have price to handle it correctly`, + { + payload, + }, + ); + + throw new Error("price is required"); + } + } + + /** + * Calculates prices + * @param payload + * @returns + */ + private _calculatePrices( + payload: Pick< + OrderDishUpdatePayload, + | "price" + | "discountAmount" + | "surchargeAmount" + | "discountPercent" + | "surchargePercent" + >, + ) { + const prices: Pick< + IOrderDish, + | "price" + | "discountAmount" + | "surchargeAmount" + | "finalPrice" + | "discountPercent" + | "surchargePercent" + > = { + price: "0", + discountAmount: "0", + surchargeAmount: "0", + discountPercent: "0", + surchargePercent: "0", + finalPrice: "0", + }; + + if (!payload.price) return {}; + + const { + price, + discountAmount, + // surchargeAmount, + discountPercent, + // surchargePercent, + } = payload; + + prices.price = price; + + if (discountAmount && !discountPercent) { + prices.discountAmount = discountAmount; + prices.discountPercent = String( + (Number(discountAmount) / Number(price)) * 100, + ); + } else if (discountPercent && !discountAmount) { + prices.discountPercent = discountPercent; + prices.discountAmount = String( + (Number(price) * Number(discountPercent)) / 100, + ); + } else if (discountAmount && discountPercent) { + // Check if provided values are correct + const _expectedDiscountAmount = + (Number(price) * Number(discountPercent)) / 100; + const _expectedDiscountPercent = + (Number(discountAmount) / Number(price)) * 100; + + if ( + _expectedDiscountAmount !== Number(discountAmount) || + _expectedDiscountPercent !== Number(discountPercent) + ) { + // throw new Error("discountAmount is incorrect"); + this.logger.warn(`Discount amount differs`, { + expected: { + discountAmount: _expectedDiscountAmount, + discountPercent: _expectedDiscountPercent, + }, + provided: { + discountAmount, + discountPercent, + }, + }); + } + } + + prices.finalPrice = String( + Number(prices.price) - + Number(prices.discountAmount) + + Number(prices.surchargeAmount), + ); + + return prices; + } + + /** + * Creates new order dish. Without any checks, just data handling (including snapshot) + * @param payload - Order dish data + * @param opts - Transaction options + * @returns Created order dish + */ + public async create( + payload: typeof orderDishes.$inferInsert, + opts?: { + tx?: DrizzleTransaction; + workerId?: string; + }, + ) { + const tx = opts?.tx ?? this.pg; + + const result = await tx.transaction(async (tx) => { + this._validatePayload(payload); + + // Insert new data to database + const [orderDish] = await tx + .insert(orderDishes) + .values({ + ...payload, + ...this._calculatePrices(payload), + }) + .returning(); + + // Calculate order totals price + await this.orderUpdatersService.calculateOrderTotals(orderDish.orderId, { + tx, + }); + + return orderDish; + }); + + // Create snapshot + await this.snapshotsProducer.create({ + model: "ORDER_DISHES", + action: "CREATE", + documentId: result.id, + data: this._proceedForSnapshot(result), + workerId: opts?.workerId, + }); + + return result; + } + + /** + * Updates order dish + * @param orderDishId - Order dish ID + * @param payload - Partial order dish data + * @param opts - Transaction options + * @returns Updated order dish + */ + public async update( + orderDishId: string, + payload: Omit, + opts?: { + tx?: DrizzleTransaction; + workerId?: string; + _ignoreHandlers?: boolean; + }, + ) { + const tx = opts?.tx ?? this.pg; + + const result = await tx.transaction(async (tx) => { + this._validatePayload(payload); + + // Update order dish + const [orderDish] = await tx + .update(orderDishes) + .set({ + ...payload, + ...this._calculatePrices(payload), + }) + .where(eq(orderDishes.id, orderDishId)) + .returning(); + + if (!opts?._ignoreHandlers) { + // When order dish is ready check for others and set order status + if (payload?.status && payload.status === "ready") { + await this.orderUpdatersService.checkDishesReadyStatus( + orderDish.orderId, + { + tx, + }, + ); + } + + // Calculate order totals price + await this.orderUpdatersService.calculateOrderTotals( + orderDish.orderId, + { + tx, + }, + ); + } + + return orderDish; + }); + + // Create snapshot + await this.snapshotsProducer.create({ + model: "ORDER_DISHES", + action: "UPDATE", + documentId: result.id, + data: this._proceedForSnapshot(result), + workerId: opts?.workerId, + }); + + return result; + } + + public async updateMany( + payload: (OrderDishUpdatePayload | null)[], + opts?: { + tx?: DrizzleTransaction; + workerId?: string; + }, + ) { + const tx = opts?.tx ?? this.pg; + + const results = await tx.transaction(async (tx) => { + const results = await Promise.all( + payload + .filter((od) => od !== null) + .map((od) => od as OrderDishUpdatePayload) + .map(({ orderDishId, ...data }, index) => { + return this.update(orderDishId, data, { + tx, + _ignoreHandlers: index !== payload.length - 1, + workerId: opts?.workerId, + }); + }), + ); + + return results; + }); + + return results; + } +} diff --git a/src/orders/@/repositories/orders.repository.ts b/src/orders/@/repositories/orders.repository.ts new file mode 100644 index 0000000..d428796 --- /dev/null +++ b/src/orders/@/repositories/orders.repository.ts @@ -0,0 +1,236 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { DrizzleTransaction, Schema } from "@postgress-db/drizzle.module"; +import { orderHistoryRecords } from "@postgress-db/schema/order-history"; +import { orders } from "@postgress-db/schema/orders"; +import { plainToClass } from "class-transformer"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { SnapshotsProducer } from "src/@base/snapshots/snapshots.producer"; +import { PG_CONNECTION } from "src/constants"; +import { OrderDishModifierEntity } from "src/orders/@/entities/order-dish-modifier.entity"; +import { OrderSnapshotEntity } from "src/orders/@/entities/order-snapshot.entity"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +@Injectable() +export class OrdersRepository { + constructor( + // Postgres connection + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + // Services + private readonly snapshotsProducer: SnapshotsProducer, + ) {} + + /** + * Attaches modifiers to order dishes + * @param orders - Orders + * @returns Orders with modifiers + */ + public attachModifiers< + T extends { + orderDishes: Array<{ + dishModifiersToOrderDishes?: { + dishModifierId: string; + dishModifier?: { name?: string | null } | null; + }[]; + }>; + }, + >( + orders: Array, + ): Array< + T & { + orderDishes: Array< + T["orderDishes"][number] & { + modifiers: OrderDishModifierEntity[]; + } + >; + } + > { + return orders.map((order) => ({ + ...order, + orderDishes: (order.orderDishes ?? []).map((dish) => ({ + ...dish, + modifiers: (dish.dishModifiersToOrderDishes + ?.map((modifier) => ({ + id: modifier.dishModifierId, + name: modifier.dishModifier?.name ?? null, + })) + .filter(Boolean) ?? []) as OrderDishModifierEntity[], + })), + })); + } + + /** + * Attaches restaurants name to orders + * @param orders - Orders + * @returns Orders with restaurants name + */ + public attachRestaurantsName< + T extends { restaurant?: { name?: string | null } | null }, + >(orders: Array): Array { + return orders.map((order) => ({ + ...order, + restaurantName: order.restaurant?.name ?? null, + })); + } + + /** + * Finds order by ID + * @param orderId - Order ID + * @returns Order + */ + public async findById(orderId: string): Promise { + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, orderId), + with: { + restaurant: { + columns: { + name: true, + }, + }, + orderDishes: { + with: { + dishModifiersToOrderDishes: { + with: { + dishModifier: { + columns: { + name: true, + }, + }, + }, + columns: { + dishModifierId: true, + }, + }, + }, + }, + }, + }); + + if (!order) return null; + + const withRestaurantsName = this.attachRestaurantsName([order])[0]; + const withModifiers = this.attachModifiers([withRestaurantsName])[0]; + + return plainToClass(OrderEntity, withModifiers, { + excludeExtraneousValues: true, + }); + } + + /** + * Proceeds data for snapshot. Excludes extraneous values + * @param result + * @returns + */ + private _proceedForSnapshot(result: typeof orders.$inferSelect) { + return plainToClass(OrderSnapshotEntity, result, { + excludeExtraneousValues: true, + }); + } + + /** + * Creates new order + * @param payload - Order data + * @param opts - Transaction options + * @returns Created order + */ + public async create( + payload: typeof orders.$inferInsert, + opts?: { + tx?: DrizzleTransaction; + workerId?: string; + }, + ) { + const tx = opts?.tx ?? this.pg; + + const result = await tx.transaction(async (tx) => { + // Insert new data to database + const [createdOrder] = await tx + .insert(orders) + .values(payload) + .returning(); + + // Create history record + await tx.insert(orderHistoryRecords).values({ + orderId: createdOrder.id, + type: "created", + workerId: opts?.workerId, + }); + + return createdOrder; + }); + + // Create snapshot + await this.snapshotsProducer.create({ + model: "ORDERS", + action: "CREATE", + documentId: result.id, + data: this._proceedForSnapshot(result), + workerId: opts?.workerId, + }); + + return result; + } + + /** + * Updates order + * @param orderId - Order ID + * @param payload - Partial order data + * @param opts - Transaction options + * @returns Updated order + */ + public async update( + orderId: string, + payload: Partial, + opts?: { tx?: DrizzleTransaction; workerId?: string }, + ) { + const tx = opts?.tx ?? this.pg; + + const result = await tx.transaction(async (tx) => { + // Update order + const [updatedOrder] = await tx + .update(orders) + .set(payload) + .where(eq(orders.id, orderId)) + .returning(); + + return updatedOrder; + }); + + if (payload.status) { + switch (payload.status) { + case "cooking": { + await tx.insert(orderHistoryRecords).values({ + orderId, + type: "sent_to_kitchen", + ...(opts?.workerId && { workerId: opts.workerId }), + }); + break; + } + case "ready": { + await tx.insert(orderHistoryRecords).values({ + orderId, + type: "dishes_ready", + ...(opts?.workerId && { workerId: opts.workerId }), + }); + + break; + } + default: { + break; + } + } + } + + // Create snapshot + await this.snapshotsProducer.create({ + model: "ORDERS", + action: "UPDATE", + documentId: result.id, + data: this._proceedForSnapshot(result), + workerId: opts?.workerId, + }); + + return result; + } +} diff --git a/src/orders/@/services/order-actions.service.ts b/src/orders/@/services/order-actions.service.ts new file mode 100644 index 0000000..25bb4ef --- /dev/null +++ b/src/orders/@/services/order-actions.service.ts @@ -0,0 +1,380 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { + orderDishes, + orderDishesReturnments, +} from "@postgress-db/schema/order-dishes"; +import { orderHistoryRecords } from "@postgress-db/schema/order-history"; +import { + orderPrecheckPositions, + orderPrechecks, +} from "@postgress-db/schema/order-prechecks"; +import { inArray } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { CreateOrderDishReturnmentDto } from "src/orders/@/dtos/create-order-dish-returnment.dto"; +import { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; +import { OrderPrecheckEntity } from "src/orders/@/entities/order-precheck.entity"; +import { OrderDishesRepository } from "src/orders/@/repositories/order-dishes.repository"; +import { OrdersRepository } from "src/orders/@/repositories/orders.repository"; +import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; + +@Injectable() +export class OrderActionsService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly ordersProducer: OrdersQueueProducer, + private readonly repository: OrdersRepository, + private readonly orderDishesRepository: OrderDishesRepository, + ) {} + + public async getAvailableActions( + id: string, + ): Promise { + const result: OrderAvailableActionsEntity = { + canPrecheck: false, + canCalculate: false, + canSendToKitchen: false, + }; + + result.canPrecheck = true; + + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, id), + columns: { + status: true, + }, + }); + + if (!order) { + throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); + } + + const orderDishes = await this.pg.query.orderDishes.findMany({ + where: (orderDishes, { and, eq, gt }) => + and( + eq(orderDishes.orderId, id), + eq(orderDishes.isRemoved, false), + gt(orderDishes.quantity, 0), + ), + columns: { + status: true, + }, + }); + + if (orderDishes.some((d) => d.status === "pending")) { + result.canSendToKitchen = true; + } + + if ( + order.status !== "pending" && + order.status !== "cooking" && + orderDishes.every((d) => d.status === "ready") + ) { + result.canCalculate = true; + } + + return result; + } + + public async sendToKitchen( + orderId: string, + opts?: { worker?: RequestWorker }, + ) { + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, orderId), + columns: { + cookingAt: true, + }, + }); + + const dishes = await this.pg.query.orderDishes.findMany({ + where: (orderDishes, { eq, and, gt }) => + and( + eq(orderDishes.orderId, orderId), + eq(orderDishes.isRemoved, false), + gt(orderDishes.quantity, 0), + ), + columns: { + id: true, + status: true, + }, + }); + + // No dishes can be sent to the kitchen + if (!dishes.some((d) => d.status === "pending")) { + throw new BadRequestException( + "errors.order-actions.no-dishes-is-pending", + ); + } + + const isAdditional = dishes.some((d) => d.status === "ready"); + const pendingDishes = dishes.filter((d) => d.status === "pending"); + + await this.pg.transaction(async (tx) => { + // Set cooking status for dishes and isAdditional flag + // TODO: Implement order-dishes repository updateMany method + await tx + .update(orderDishes) + .set({ + status: "cooking", + isAdditional, + cookingAt: new Date(), + }) + .where( + // Change status of pending dishes to cooking + inArray( + orderDishes.id, + pendingDishes.map((d) => d.id), + ), + ); + + // Set cooking status for order + await this.repository.update( + orderId, + { + status: "cooking", + cookingAt: + order && order.cookingAt ? new Date(order.cookingAt) : new Date(), + }, + { + workerId: opts?.worker?.id, + }, + ); + }); + + // Notify users + await this.ordersProducer.update({ + orderId, + }); + + await this.ordersProducer.newOrderAtKitchen(orderId); + } + + public async makeOrderDishReturnment( + orderDishId: string, + payload: CreateOrderDishReturnmentDto, + opts: { worker: RequestWorker }, + ) { + const orderDish = await this.pg.query.orderDishes.findFirst({ + where: (orderDishes, { eq, and }) => + and(eq(orderDishes.id, orderDishId), eq(orderDishes.isRemoved, false)), + columns: { + status: true, + quantity: true, + quantityReturned: true, + }, + with: { + order: { + columns: { + id: true, + restaurantId: true, + }, + }, + }, + }); + + if (!orderDish || !orderDish.order) { + throw new NotFoundException(); + } + + if ( + opts.worker.role === "SYSTEM_ADMIN" || + opts.worker.role === "CHIEF_ADMIN" + ) { + } + // Owner role handling + else if (opts.worker.role === "OWNER") { + if ( + !opts.worker.ownedRestaurants.some( + (r) => r.id === orderDish.order.restaurantId, + ) + ) { + throw new ForbiddenException( + "errors.order-actions.not-enough-rights-to-make-returnment", + ); + } + } + // Assigned admins or cashiers + else if (opts.worker.role === "ADMIN" || opts.worker.role === "CASHIER") { + if ( + !opts.worker.workersToRestaurants.some( + (w) => w.restaurantId === orderDish.order.restaurantId, + ) + ) { + throw new ForbiddenException( + "errors.order-actions.not-enough-rights-to-make-returnment", + ); + } + } + // Other roles + else { + throw new ForbiddenException( + "errors.order-actions.not-enough-rights-to-make-returnment", + ); + } + + if (orderDish.status !== "completed" && orderDish.status !== "ready") { + throw new BadRequestException( + "errors.order-dishes.cant-make-returnment-for-not-completed-or-ready-dish", + ); + } + + if (orderDish.quantity === 0) { + throw new BadRequestException( + "errors.order-dishes.cant-make-returnment-for-dish-with-zero-quantity", + ); + } + + if (payload.quantity > orderDish.quantity) { + throw new BadRequestException( + "errors.order- dishes.cant-make-returnment-for-more-than-added", + ); + } + + const quantity = orderDish.quantity - payload.quantity; + const quantityReturned = orderDish.quantityReturned + payload.quantity; + + await this.pg.transaction(async (tx) => { + // Update order dish + await this.orderDishesRepository.update( + orderDishId, + { + quantity, + quantityReturned, + }, + { + tx, + workerId: opts.worker.id, + }, + ); + + // Create returnment + await this.pg.insert(orderDishesReturnments).values({ + orderDishId, + quantity: payload.quantity, + reason: payload.reason, + workerId: opts.worker.id, + // TODO: Implement isDoneAfterPrecheck flag + isDoneAfterPrecheck: false, + }); + }); + + await this.ordersProducer.update({ + orderId: orderDish.order.id, + }); + } + + public async createPrecheck( + orderId: string, + opts: { worker: RequestWorker }, + ): Promise { + const availableActions = await this.getAvailableActions(orderId); + + if (!availableActions.canPrecheck) { + throw new BadRequestException( + "errors.order-actions.cant-create-precheck", + ); + } + + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, orderId), + columns: { + id: true, + type: true, + restaurantId: true, + currency: true, + }, + with: { + orderDishes: { + where: (orderDishes, { eq, and, gt }) => + and(eq(orderDishes.isRemoved, false), gt(orderDishes.quantity, 0)), + columns: { + name: true, + quantity: true, + price: true, + discountAmount: true, + surchargeAmount: true, + finalPrice: true, + }, + }, + restaurant: { + columns: { + legalEntity: true, + currency: true, + }, + }, + }, + }); + + if (!order) { + throw new BadRequestException(); + } + + const precheck = await this.pg.transaction(async (tx) => { + const { type, restaurant, currency } = order; + + const [precheck] = await tx + .insert(orderPrechecks) + .values({ + orderId, + type, + legalEntity: restaurant?.legalEntity ?? "", + locale: "ru", + currency, + workerId: opts.worker.id, + }) + .returning({ + id: orderPrechecks.id, + createdAt: orderPrechecks.createdAt, + }); + + await tx.insert(orderPrecheckPositions).values( + order.orderDishes.map((d) => ({ + precheckId: precheck.id, + ...d, + })), + ); + + await tx.insert(orderHistoryRecords).values({ + id: precheck.id, + orderId, + type: "precheck", + createdAt: precheck.createdAt, + workerId: opts.worker.id, + }); + + return precheck; + }); + + const result = await this.pg.query.orderPrechecks.findFirst({ + where: (orderPrechecks, { eq }) => eq(orderPrechecks.id, precheck.id), + with: { + worker: { + columns: { + name: true, + role: true, + }, + }, + positions: true, + order: { + columns: { + number: true, + }, + }, + }, + }); + + if (!result) { + throw new ServerErrorException(); + } + + return result; + } +} diff --git a/src/orders/@/services/order-discounts.service.ts b/src/orders/@/services/order-discounts.service.ts new file mode 100644 index 0000000..e9f8a43 --- /dev/null +++ b/src/orders/@/services/order-discounts.service.ts @@ -0,0 +1,369 @@ +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { + discountsConnections, + discountsToGuests, +} from "@postgress-db/schema/discounts"; +import { IOrderDish } from "@postgress-db/schema/order-dishes"; +import { orderHistoryRecords } from "@postgress-db/schema/order-history"; +import { orders } from "@postgress-db/schema/orders"; +// import { discountsToRestaurants } from "@postgress-db/schema/discounts"; +import { arrayOverlaps, eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { + OrderDishesRepository, + OrderDishUpdatePayload, +} from "src/orders/@/repositories/order-dishes.repository"; +import { TimezonesService } from "src/timezones/timezones.service"; + +type connectionKey = string; +type OrderDiscount = { + id: string; + percent: number; + connectionsSet: Set; +}; + +@Injectable() +export class OrderDiscountsService { + private readonly logger = new Logger(OrderDiscountsService.name); + + constructor( + // DB Connection + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly timezonesService: TimezonesService, + private readonly orderDishesRepository: OrderDishesRepository, + ) {} + + private _getConnectionKey( + dishMenuId: string, + dishCategoryId: string, + ): connectionKey { + return `${dishMenuId}:${dishCategoryId}`; + } + + /** + * Get discounts for the order + * @param orderId - Order ID + * @returns Discounts + */ + public async getDiscounts(orderId: string): Promise { + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, orderId), + columns: { + type: true, + from: true, + discountsGuestId: true, + restaurantId: true, + }, + with: { + restaurant: { + columns: { + timezone: true, + }, + }, + discountsToOrders: { + columns: { + discountId: true, + }, + }, + }, + }); + + if (!order || !order.restaurant) { + return []; + } + + const timezone = order.restaurant.timezone; + + const currentDayOfWeek = + this.timezonesService.getCurrentDayOfWeek(timezone); + + const currentTime = this.timezonesService.getCurrentTime(timezone); + + const additionalDiscountsIds = order.discountsToOrders.map( + ({ discountId }) => discountId, + ); + + const discounts = await this.pg.query.discounts.findMany({ + where: ( + discounts, + { or, eq, exists, notExists, and, isNull, lte, gte, inArray }, + ) => { + return or( + // Additional order discounts should be applied without additional checks + // Cause this checks should be performed before creating record + additionalDiscountsIds.length > 0 + ? inArray(discounts.id, additionalDiscountsIds) + : undefined, + // General search + and( + // Active from to date + and( + lte(discounts.activeFrom, new Date()), + gte(discounts.activeTo, new Date()), + ), + // Check that discount is enabled + eq(discounts.isEnabled, true), + // Check that type and from are in the discount + arrayOverlaps(discounts.orderFroms, [order.from]), + arrayOverlaps(discounts.orderTypes, [order.type]), + // Check that discount is active for the current day of week + arrayOverlaps(discounts.daysOfWeek, [currentDayOfWeek]), + // Check restaurants assigned to the discount + exists( + this.pg + .select({ + restaurantId: discountsConnections.restaurantId, + }) + .from(discountsConnections) + .where( + and( + eq(discountsConnections.discountId, discounts.id), + eq( + discountsConnections.restaurantId, + String(order.restaurantId), + ), + ), + ), + ), + or( + // NULL values means that discount is active all the time + and(isNull(discounts.startTime), isNull(discounts.endTime)), + // Check if current time is between start and end time + and( + lte(discounts.startTime, currentTime), + gte(discounts.endTime, currentTime), + ), + ), + order.discountsGuestId + ? // If order have discounts guest id load both: shared and personal discounts + or( + exists( + this.pg + .select({ + discountId: discountsToGuests.discountId, + }) + .from(discountsToGuests) + .where( + and( + eq(discountsToGuests.discountId, discounts.id), + eq(discountsToGuests.guestId, order.discountsGuestId), + ), + ), + ), + notExists( + this.pg + .select({ + discountId: discountsToGuests.discountId, + }) + .from(discountsToGuests) + .where(eq(discountsToGuests.discountId, discounts.id)), + ), + ) + : // If not only discounts that doesn't have guests assigns + notExists( + this.pg + .select({ + discountId: discountsToGuests.discountId, + }) + .from(discountsToGuests) + .where(eq(discountsToGuests.discountId, discounts.id)), + ), + ), + ); + }, + columns: { + id: true, + percent: true, + }, + with: { + connections: { + where: (connections, { eq }) => + eq(connections.restaurantId, String(order.restaurantId)), + columns: { + dishesMenuId: true, + dishCategoryId: true, + }, + }, + }, + }); + + return discounts.map(({ id, percent, connections }) => ({ + id, + percent, + connectionsSet: new Set( + connections.map(({ dishesMenuId, dishCategoryId }) => + this._getConnectionKey(dishesMenuId, dishCategoryId), + ), + ), + })); + } + + /** + * Get max discounts map + * @param discounts + * @returns + */ + private _getMaxDiscountsMap(discounts: OrderDiscount[]) { + const discountsMap = new Map< + connectionKey, + { id: string; percent: number } + >(); + + // sort from lower to higher percent + discounts + .sort((a, b) => a.percent - b.percent) + .forEach((discount) => { + Array.from(discount.connectionsSet).forEach((connectionKey) => { + discountsMap.set(connectionKey, { + id: discount.id, + percent: discount.percent, + }); + }); + }); + + return discountsMap; + } + + /** + * Get order dishes discounts map + * @param orderDishes + * @param maxDiscounts + * @returns + */ + private _getOrderDishesDiscountsMap( + orderDishes: Array< + Pick & { + menuId: string; + categoryIds: string[]; + } + >, + maxDiscounts: Map, + ) { + const orderDishIdToDiscount = new Map< + string, + { id: string; percent: number } + >(); + + orderDishes.forEach(({ id, menuId, categoryIds }) => { + categoryIds.forEach((categoryId) => { + const connectionKey = this._getConnectionKey(menuId, categoryId); + const discount = maxDiscounts.get(connectionKey); + + if (!discount) { + return; + } + + orderDishIdToDiscount.set(id, discount); + }); + }); + + return orderDishIdToDiscount; + } + + /** + * Apply discounts to the order dishes + * @param orderId - Order ID + */ + public async applyDiscounts( + orderId: string, + opts?: { worker?: RequestWorker }, + ) { + const discounts = await this.getDiscounts(orderId); + const maxDiscounts = this._getMaxDiscountsMap(discounts); + + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, orderId), + columns: { + applyDiscounts: true, + }, + }); + + if (!order) { + throw new Error("Order not found"); + } + + const orderDishes = await this.pg.query.orderDishes.findMany({ + where: (orderDishes, { and, eq, gt, isNull }) => + and( + eq(orderDishes.orderId, orderId), + eq(orderDishes.isRemoved, false), + gt(orderDishes.quantity, 0), + isNull(orderDishes.discountId), + ), + columns: { + id: true, + price: true, + surchargeAmount: true, + }, + with: { + dish: { + columns: { + menuId: true, + }, + with: { + dishesToDishCategories: { + columns: { + dishCategoryId: true, + }, + }, + }, + }, + }, + }); + + const orderDishIdToDiscount = this._getOrderDishesDiscountsMap( + orderDishes.map((od) => ({ + ...od, + menuId: String(od.dish?.menuId), + categoryIds: (od.dish?.dishesToDishCategories || []).map( + ({ dishCategoryId }) => dishCategoryId, + ), + })), + maxDiscounts, + ); + + await this.pg.transaction(async (tx) => { + await this.orderDishesRepository.updateMany( + orderDishes + .map(({ id, price, surchargeAmount }) => { + const discount = orderDishIdToDiscount.get(id); + + if (!discount) { + return null; + } + + return { + orderDishId: id, + discountId: discount.id, + discountPercent: discount.percent.toString(), + price, + surchargeAmount, + } satisfies OrderDishUpdatePayload; + }) + .filter((od) => od !== null), + { + tx, + workerId: opts?.worker?.id, + }, + ); + + if (!order.applyDiscounts) { + await tx + .update(orders) + .set({ + applyDiscounts: true, + }) + .where(eq(orders.id, orderId)); + + await tx.insert(orderHistoryRecords).values({ + orderId, + type: "discounts_enabled", + ...(opts?.worker?.id && { workerId: opts.worker.id }), + }); + } + }); + } +} diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts new file mode 100644 index 0000000..f0c47f3 --- /dev/null +++ b/src/orders/@/services/order-dishes.service.ts @@ -0,0 +1,313 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { dishModifiersToOrderDishes } from "@postgress-db/schema/dish-modifiers"; +import { eq, sql } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; +import { PutOrderDishModifiersDto } from "src/orders/@/dtos/put-order-dish-modifiers"; +import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; +import { OrderDishesRepository } from "src/orders/@/repositories/order-dishes.repository"; +import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; + +@Injectable() +export class OrderDishesService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly repository: OrderDishesRepository, + private readonly ordersProducer: OrdersQueueProducer, + ) {} + + private readonly getOrderStatement = this.pg.query.orders + .findFirst({ + where: (orders, { eq, and }) => + and( + eq(orders.id, sql.placeholder("orderId")), + eq(orders.isRemoved, false), + eq(orders.isArchived, false), + ), + columns: { + restaurantId: true, + }, + }) + .prepare(`${OrderDishesService.name}_getOrder`); + + private async getOrder(orderId: string) { + const order = await this.getOrderStatement.execute({ + orderId, + }); + + if (!order) { + throw new NotFoundException("errors.order-dishes.order-not-found"); + } + + if (!order.restaurantId) { + throw new BadRequestException( + "errors.order-dishes.restaurant-not-assigned", + ); + } + + return order; + } + + public async getOrderDish(orderDishId: string) { + const orderDish = await this.pg.query.orderDishes.findFirst({ + where: (orderDishes, { eq }) => eq(orderDishes.id, orderDishId), + }); + + if (!orderDish) { + throw new NotFoundException("errors.order-dishes.order-dish-not-found"); + } + + return orderDish; + } + + public async getDishForRestaurant(dishId: string, restaurantId: string) { + const dish = await this.pg.query.dishes.findFirst({ + where: (dishes, { eq }) => eq(dishes.id, dishId), + with: { + dishesToRestaurants: { + where: (dishesToRestaurants, { eq }) => + eq(dishesToRestaurants.restaurantId, restaurantId), + columns: { + price: true, + currency: true, + isInStopList: true, + }, + }, + }, + columns: { + name: true, + }, + }); + + if (!dish) { + throw new NotFoundException("errors.order-dishes.dish-not-found"); + } + + if (dish.dishesToRestaurants.length === 0) { + throw new NotFoundException("errors.order-dishes.dish-not-assigned"); + } + + return { + ...dish, + ...dish.dishesToRestaurants[0], + }; + } + + public async getIsAdditional(orderId: string, dishId: string) { + const existing = await this.pg.query.orderDishes.findMany({ + where: (orderDishes, { and, eq, isNull }) => + and( + eq(orderDishes.orderId, orderId), + eq(orderDishes.dishId, dishId), + // Exclude removed + isNull(orderDishes.removedAt), + eq(orderDishes.isRemoved, false), + ), + columns: { + status: true, + }, + }); + + if (existing.length === 0) { + return false; + } + + // If none is pending, then it's additional (cause other are in process or already completed) + if (existing.every(({ status }) => status !== "pending")) { + return true; + } + + if (existing.some(({ status }) => status === "pending")) { + throw new BadRequestException( + "errors.order-dishes.dish-already-in-order", + ); + } + + return true; + } + + public async addToOrder( + orderId: string, + payload: AddOrderDishDto, + opts?: { workerId?: string }, + ) { + const { dishId, quantity } = payload; + + const order = await this.getOrder(orderId); + const dish = await this.getDishForRestaurant( + payload.dishId, + String(order.restaurantId), + ); + + const isAdditional = await this.getIsAdditional(orderId, dishId); + + const price = Number(dish.price); + + const orderDish = await this.repository.create( + { + orderId, + dishId: payload.dishId, + name: dish.name, + status: "pending", + quantity, + isAdditional, + price: String(price), + finalPrice: String(price), + }, + { + workerId: opts?.workerId, + }, + ); + + await this.ordersProducer.dishUpdate({ + orderDish, + }); + + return orderDish; + } + + public async update( + orderDishId: string, + payload: UpdateOrderDishDto, + opts?: { workerId?: string }, + ) { + const { quantity } = payload; + + if (quantity <= 0) { + throw new BadRequestException( + "errors.order-dishes.cant-set-zero-quantity", + ); + } + + const orderDish = await this.getOrderDish(orderDishId); + + if (orderDish.status !== "pending") { + throw new BadRequestException( + "errors.order-dishes.cant-update-not-pending-order-dish", + ); + } + + if (orderDish.isRemoved) { + throw new BadRequestException("errors.order-dishes.is-removed"); + } + + const updatedOrderDish = await this.repository.update( + orderDishId, + { + quantity, + }, + { + workerId: opts?.workerId, + }, + ); + + await this.ordersProducer.dishUpdate({ + orderDish: updatedOrderDish, + }); + + return updatedOrderDish; + } + + public async remove(orderDishId: string, opts?: { workerId?: string }) { + const orderDish = await this.getOrderDish(orderDishId); + + if (orderDish.isRemoved) { + throw new BadRequestException("errors.order-dishes.already-removed"); + } + + const removedOrderDish = await this.repository.update( + orderDishId, + { + isRemoved: true, + removedAt: new Date(), + }, + { + workerId: opts?.workerId, + }, + ); + + await this.ordersProducer.dishUpdate({ + orderDish: removedOrderDish, + }); + + return removedOrderDish; + } + + public async updateDishModifiers( + orderDishId: string, + payload: PutOrderDishModifiersDto, + ) { + const orderDish = await this.getOrderDish(orderDishId); + + if (orderDish.isRemoved) { + throw new BadRequestException("errors.order-dishes.is-removed"); + } + + if (orderDish.status === "cooking") { + throw new BadRequestException( + "errors.order-dishes.cant-update-cooking-dish", + ); + } + + if (orderDish.status !== "pending") { + throw new BadRequestException( + "errors.order-dishes.cant-update-ready-dish", + ); + } + + const order = await this.getOrder(orderDish.orderId); + + const dishModifiers = await this.pg.query.dishModifiers.findMany({ + where: (dishModifiers, { inArray, and, eq }) => + and( + inArray(dishModifiers.id, payload.dishModifierIds), + eq(dishModifiers.isActive, true), + eq(dishModifiers.isRemoved, false), + ), + columns: { + id: true, + restaurantId: true, + }, + }); + + if ( + dishModifiers.some( + ({ restaurantId }) => restaurantId !== order.restaurantId, + ) + ) { + throw new BadRequestException( + "errors.order-dish-modifiers.some-dish-modifiers-not-assigned-to-restaurant", + ); + } + + if (dishModifiers.length !== payload.dishModifierIds.length) { + throw new BadRequestException( + "errors.order-dish-modifiers.some-dish-modifiers-not-found", + ); + } + + await this.pg.transaction(async (tx) => { + // Delete all existing dish modifiers for this order dish + await tx + .delete(dishModifiersToOrderDishes) + .where(eq(dishModifiersToOrderDishes.orderDishId, orderDishId)); + + // Insert new dish modifiers for this order dish + if (payload.dishModifierIds.length > 0) { + await tx.insert(dishModifiersToOrderDishes).values( + dishModifiers.map(({ id }) => ({ + dishModifierId: id, + orderDishId, + })), + ); + } + }); + + return orderDish; + } +} diff --git a/src/orders/@/services/order-history.service.ts b/src/orders/@/services/order-history.service.ts new file mode 100644 index 0000000..14af8a9 --- /dev/null +++ b/src/orders/@/services/order-history.service.ts @@ -0,0 +1,151 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { DrizzleTransaction, schema } from "@postgress-db/drizzle.module"; +import { orderHistoryRecords } from "@postgress-db/schema/order-history"; +import { count, eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { OrderHistoryRecordEntity } from "src/orders/@/entities/order-history-record.entity"; +import { OrderHistoryEntity } from "src/orders/@/entities/order-history.entity"; +import { OrderPrecheckEntity } from "src/orders/@/entities/order-precheck.entity"; + +@Injectable() +export class OrderHistoryService { + constructor( + // DB Connection + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + /** + * + * @param tx + * @param precheckIds + * @returns + */ + private async _getPrechecks( + tx: DrizzleTransaction, + precheckIds: string[], + ): Promise { + if (precheckIds.length === 0) { + return []; + } + + return await this.pg.query.orderPrechecks.findMany({ + where: (prechecks, { inArray }) => inArray(prechecks.id, precheckIds), + with: { + worker: { + columns: { + name: true, + role: true, + }, + }, + positions: true, + order: { + columns: { + number: true, + }, + }, + }, + }); + } + + /** + * + * @param tx + * @param records + * @returns + */ + private async _assignDataToRecords( + tx: DrizzleTransaction, + records: OrderHistoryRecordEntity[], + ): Promise { + const dataMap = new Map(); + const precheckIdsSet = new Set(); + + records.forEach((record) => { + if (record.type === "precheck") { + precheckIdsSet.add(record.id); + } + }); + + const prechecks = await this._getPrechecks(tx, Array.from(precheckIdsSet)); + + prechecks.forEach((precheck) => { + dataMap.set(precheck.id, precheck); + }); + + return records.map((record) => { + return { + ...record, + precheck: null, + // If record is a precheck, add the precheck data + ...(record.type === "precheck" && { + precheck: (dataMap.get(record.id) || + null) as OrderPrecheckEntity | null, + }), + }; + }); + } + + public async getTotalCount(orderId: string): Promise { + const [result] = await this.pg + .select({ + count: count(), + }) + .from(orderHistoryRecords) + .where(eq(orderHistoryRecords.orderId, orderId)); + + return result?.count || 0; + } + + /** + * Finds all history records for an order + * @param orderId + * @param options + * @returns + */ + public async findMany( + orderId: string, + options?: { + limit?: number; + offset?: number; + }, + ): Promise { + // Pagination + const { limit, offset } = options || { + limit: 50, + offset: 0, + }; + + const history = await this.pg.transaction(async (tx) => { + // Get records to then fetch their data + const records = await tx.query.orderHistoryRecords.findMany({ + where: (records, { eq }) => eq(records.orderId, orderId), + limit, + offset, + // From newest to oldest + orderBy: (records, { desc }) => desc(records.createdAt), + columns: { + id: true, + orderId: true, + type: true, + workerId: true, + createdAt: true, + }, + with: { + worker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + }, + }); + + return this._assignDataToRecords(tx, records); + }); + + return history; + } +} diff --git a/src/orders/@/services/order-menu.service.ts b/src/orders/@/services/order-menu.service.ts new file mode 100644 index 0000000..f048574 --- /dev/null +++ b/src/orders/@/services/order-menu.service.ts @@ -0,0 +1,274 @@ +import { ICursor } from "@core/decorators/cursor.decorator"; +import { PAGINATION_DEFAULT_LIMIT } from "@core/decorators/pagination.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { dishCategories } from "@postgress-db/schema/dish-categories"; +import { + dishes, + dishesToDishCategories, + dishesToRestaurants, +} from "@postgress-db/schema/dishes"; +import { asc, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { DishCategoryEntity } from "src/dish-categories/entities/dish-category.entity"; +import { + OrderMenuDishEntity, + OrderMenuDishOrderDishEntity, +} from "src/orders/@/entities/order-menu-dish.entity"; + +@Injectable() +export class OrderMenuService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + public async findDishCategories( + orderId: string, + ): Promise { + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, orderId), + columns: {}, + with: { + restaurant: { + columns: {}, + with: { + dishesMenusToRestaurants: { + columns: { + dishesMenuId: true, + }, + }, + }, + }, + }, + }); + const menuIds = (order?.restaurant?.dishesMenusToRestaurants || []).map( + ({ dishesMenuId }) => dishesMenuId, + ); + + if (menuIds.length === 0) { + return []; + } + + const fetchedCategories = await this.pg.query.dishCategories.findMany({ + where: (dishCategories, { and, eq, inArray }) => { + return and( + eq(dishCategories.showForWorkers, true), + inArray(dishCategories.menuId, menuIds), + ); + }, + orderBy: [asc(dishCategories.sortIndex)], + }); + + return fetchedCategories; + } + + public async getDishes( + orderId: string, + opts?: { + cursor: ICursor; + search?: string; + dishCategoryId?: string; + }, + ): Promise { + const { cursor, search, dishCategoryId } = opts ?? {}; + + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, orderId), + columns: { + id: true, + restaurantId: true, + }, + with: { + orderDishes: { + // Fetch pending dishes + where: (orderDishes, { and, eq }) => + and( + eq(orderDishes.status, "pending"), + eq(orderDishes.isRemoved, false), + ), + columns: { + id: true, + dishId: true, + price: true, + quantity: true, + discountPercent: true, + surchargePercent: true, + finalPrice: true, + }, + with: { + dishModifiersToOrderDishes: { + with: { + dishModifier: { + columns: { + name: true, + }, + }, + }, + columns: { + dishModifierId: true, + }, + }, + }, + }, + restaurant: { + columns: { + id: true, + }, + with: { + dishesMenusToRestaurants: { + columns: { + dishesMenuId: true, + }, + }, + }, + }, + }, + }); + + const menuIds = ( + order?.restaurant?.dishesMenusToRestaurants as { dishesMenuId: string }[] + ) + .map((d) => d.dishesMenuId) + .filter(Boolean); + + if (!order) { + throw new NotFoundException(); + } + + if (!order.restaurantId) { + throw new BadRequestException( + "errors.order-menu.order-doesnt-have-restaurant", + ); + } + + const fetchedDishes = await this.pg.query.dishes.findMany({ + where: (dishes, { and, eq, exists, inArray, ilike }) => { + const conditions: SQL[] = [ + // From the menu that is assigned to the order restaurant + inArray(dishes.menuId, menuIds), + // Select only dishes that was assigned to the order restaurant + exists( + this.pg + .select({ + id: dishesToRestaurants.dishId, + }) + .from(dishesToRestaurants) + .where( + and( + eq( + dishesToRestaurants.restaurantId, + String(order.restaurantId), + ), + eq(dishesToRestaurants.dishId, dishes.id), + ), + ), + ), + ]; + + if (search && search !== "null") { + conditions.push(ilike(dishes.name, `%${search}%`)); + } + + if (dishCategoryId) { + conditions.push( + exists( + this.pg + .select() + .from(dishesToDishCategories) + .where( + and( + eq(dishesToDishCategories.dishId, dishes.id), + eq(dishesToDishCategories.dishCategoryId, dishCategoryId), + ), + ), + ), + ); + } + + return and(...conditions); + }, + columns: { + id: true, + name: true, + cookingTimeInMin: true, + amountPerItem: true, + }, + with: { + dishesToRestaurants: { + where: (dishesToRestaurants, { and, eq }) => + and( + eq(dishesToRestaurants.restaurantId, String(order.restaurantId)), + ), + // Load stop list status + columns: { + dishId: true, + isInStopList: true, + currency: true, + }, + }, + dishesToImages: { + with: { + imageFile: true, + }, + }, + }, + orderBy: [asc(dishes.id)], + limit: cursor?.limit ?? PAGINATION_DEFAULT_LIMIT, + }); + + const dishIdToOrderDishMap = new Map( + order.orderDishes.map((d) => [d.dishId, d]), + ); + + return fetchedDishes.map( + ({ dishesToRestaurants, dishesToImages, ...dish }) => { + const { currency, isInStopList } = dishesToRestaurants[0] ?? {}; + + let orderDish: OrderMenuDishOrderDishEntity | null = null; + + if (dishIdToOrderDishMap.has(dish.id)) { + const { ...rest } = dishIdToOrderDishMap.get(dish.id); + + const dishModifiersToOrderDishes = (rest as any) + .dishModifiersToOrderDishes as unknown as { + dishModifierId: string; + dishModifier: { name: string }; + }[]; + + orderDish = { + ...rest, + // @ts-expect-error - TODO: fix this + dishModifiersToOrderDishes: undefined, + currency, + modifiers: ( + dishModifiersToOrderDishes as unknown as { + dishModifierId: string; + dishModifier: { name: string }; + }[] + ).map(({ dishModifierId, dishModifier }) => ({ + id: dishModifierId, + name: dishModifier.name, + })), + }; + } + + return { + ...dish, + orderDish, + images: dishesToImages + .sort((a, b) => a.sortIndex - b.sortIndex) + .map((di) => ({ + ...di.imageFile, + alt: di.alt, + sortIndex: di.sortIndex, + })), + isInStopList, + }; + }, + ); + } +} diff --git a/src/orders/@/services/order-updaters.service.ts b/src/orders/@/services/order-updaters.service.ts new file mode 100644 index 0000000..c4377f9 --- /dev/null +++ b/src/orders/@/services/order-updaters.service.ts @@ -0,0 +1,103 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { DrizzleTransaction, Schema } from "@postgress-db/drizzle.module"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { OrdersRepository } from "src/orders/@/repositories/orders.repository"; + +@Injectable() +export class OrderUpdatersService { + private readonly logger = new Logger(OrderUpdatersService.name); + + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly repository: OrdersRepository, + ) {} + + public async checkDishesReadyStatus( + orderId: string, + opts?: { tx?: DrizzleTransaction }, + ) { + const tx = opts?.tx ?? this.pg; + + const order = await tx.query.orders.findFirst({ + where: (orders, { and, eq }) => + and(eq(orders.id, orderId), eq(orders.status, "cooking")), + columns: {}, + with: { + orderDishes: { + where: (orderDishes, { eq, and, gt }) => + and(eq(orderDishes.isRemoved, false), gt(orderDishes.quantity, 0)), + columns: { + status: true, + }, + }, + }, + }); + + if (!order) return; + + if (order.orderDishes.every((d) => d.status === "ready")) { + await this.repository.update( + orderId, + { + status: "ready", + }, + { + tx: opts?.tx, + }, + ); + } + } + + public async calculateOrderTotals( + orderId: string, + opts?: { + tx?: DrizzleTransaction; + }, + ) { + const tx = opts?.tx ?? this.pg; + + const orderDishes = await tx.query.orderDishes.findMany({ + where: (orderDishes, { eq, and, gt }) => + and( + eq(orderDishes.orderId, orderId), + eq(orderDishes.isRemoved, false), + gt(orderDishes.quantity, 0), + ), + columns: { + price: true, + quantity: true, + finalPrice: true, + surchargeAmount: true, + discountAmount: true, + }, + }); + + const prices = orderDishes.reduce( + (acc, dish) => { + acc.subtotal += Number(dish.price) * Number(dish.quantity); + acc.surchargeAmount += + Number(dish.surchargeAmount) * Number(dish.quantity); + acc.discountAmount += + Number(dish.discountAmount) * Number(dish.quantity); + acc.total += Number(dish.finalPrice) * Number(dish.quantity); + return acc; + }, + { subtotal: 0, surchargeAmount: 0, discountAmount: 0, total: 0 }, + ); + + await this.repository.update( + orderId, + { + subtotal: prices.subtotal.toString(), + surchargeAmount: prices.surchargeAmount.toString(), + discountAmount: prices.discountAmount.toString(), + total: prices.total.toString(), + }, + { + tx: opts?.tx, + }, + ); + } +} diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts new file mode 100644 index 0000000..013cfe9 --- /dev/null +++ b/src/orders/@/services/orders.service.ts @@ -0,0 +1,327 @@ +import { IFilters } from "@core/decorators/filter.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { DrizzleUtils } from "@postgress-db/drizzle-utils"; +import { Schema } from "@postgress-db/drizzle.module"; +import { orderNumberBroneering, orders } from "@postgress-db/schema/orders"; +import { count, desc, sql } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { GuestsService } from "src/guests/guests.service"; +import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; +import { UpdateOrderDto } from "src/orders/@/dtos/update-order.dto"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { OrdersRepository } from "src/orders/@/repositories/orders.repository"; +import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; + +@Injectable() +export class OrdersService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly guestsService: GuestsService, + private readonly ordersQueueProducer: OrdersQueueProducer, + private readonly repository: OrdersRepository, + ) {} + + private async generateOrderNumber() { + // get last broneering + const lastBroneering = await this.pg.query.orderNumberBroneering.findFirst({ + orderBy: desc(orderNumberBroneering.createdAt), + }); + + let number = "1"; + + if (lastBroneering) { + number = `${Number(lastBroneering.number) + 1}`; + } + + await this.pg.insert(orderNumberBroneering).values({ + number, + }); + + return number; + } + + private readonly checkTableNumberStatement = this.pg.query.orders + .findFirst({ + where: (orders, { eq, and, inArray }) => + and( + eq(orders.tableNumber, sql.placeholder("tableNumber")), + eq(orders.restaurantId, sql.placeholder("restaurantId")), + inArray(orders.status, ["pending", "cooking", "ready", "paid"]), + ), + columns: { + id: true, + }, + }) + .prepare(`${OrdersService.name}_checkTableNumber`); + + public async checkTableNumber( + restaurantId: string, + tableNumber: string, + orderId?: string, + ) { + const order = await this.checkTableNumberStatement.execute({ + restaurantId, + tableNumber, + }); + + if (order && order.id !== orderId) { + throw new BadRequestException( + "errors.orders.table-number-is-already-taken", + { + property: "tableNumber", + }, + ); + } + } + + public async checkDto(dto: UpdateOrderDto, orderId?: string) { + if (dto.type === "banquet" || dto.type === "hall") { + // Table number is required for banquet and hall + if (!dto.tableNumber || dto.tableNumber === "") { + throw new BadRequestException( + "errors.orders.table-number-is-required", + { + property: "tableNumber", + }, + ); + } + + if (!dto.restaurantId) { + throw new BadRequestException( + "errors.orders.restaurant-is-required-for-banquet-or-hall", + { + property: "restaurantId", + }, + ); + } + + if (dto.restaurantId) { + await this.checkTableNumber(dto.restaurantId, dto.tableNumber, orderId); + } + } + + // Phone number is required for delivery, takeaway and banquet + if ( + (!dto.guestPhone || dto.guestPhone === "") && + (dto.type === "delivery" || + dto.type === "takeaway" || + dto.type === "banquet") + ) { + throw new BadRequestException("errors.orders.phone-number-is-required", { + property: "guestPhone", + }); + } + + if (!dto.paymentMethodId && !!dto.restaurantId) { + throw new BadRequestException( + "errors.orders.payment-method-is-required", + { + property: "paymentMethodId", + }, + ); + } + + if (!!dto.paymentMethodId) { + const paymentMethod = await this.pg.query.paymentMethods.findFirst({ + where: (paymentMethods, { eq }) => + eq(paymentMethods.id, String(dto.paymentMethodId)), + columns: { + isActive: true, + restaurantId: true, + }, + }); + + if (!paymentMethod) { + throw new BadRequestException( + "errors.orders.payment-method-not-found", + { + property: "paymentMethodId", + }, + ); + } + + if (paymentMethod.isActive === false) { + throw new BadRequestException( + "errors.orders.payment-method-is-not-active", + { + property: "paymentMethodId", + }, + ); + } + + if (paymentMethod.restaurantId !== dto.restaurantId) { + throw new BadRequestException( + "errors.orders.payment-method-is-not-for-this-restaurant", + { + property: "paymentMethodId", + }, + ); + } + } + } + + async create( + dto: CreateOrderDto, + opts?: { workerId?: string }, + ): Promise { + await this.checkDto(dto); + + const { + type, + guestName, + guestPhone, + guestsAmount, + note, + delayedTo, + restaurantId, + tableNumber, + paymentMethodId, + } = dto; + + const number = await this.generateOrderNumber(); + const guest = await this.guestsService.findByPhoneNumber(guestPhone); + + const restaurant = await this.pg.query.restaurants.findFirst({ + where: (restaurants, { eq }) => eq(restaurants.id, String(restaurantId)), + columns: { + currency: true, + }, + }); + + if (!restaurant) { + throw new ServerErrorException(); + } + + const createdOrder = await this.repository.create( + { + number, + tableNumber, + type, + from: "internal", + status: "pending", + currency: restaurant?.currency, + paymentMethodId, + ...(delayedTo ? { delayedTo: new Date(delayedTo) } : {}), + guestsAmount, + note, + restaurantId, + + // Guest info // + guestId: guest?.id, + guestName: guestName ?? guest?.name, + guestPhone, + }, + { + workerId: opts?.workerId, + }, + ); + + const order = await this.findById(createdOrder.id); + + // Notify users + await this.ordersQueueProducer.newOrder(createdOrder.id); + + return order; + } + + async update( + id: string, + dto: UpdateOrderDto, + opts?: { workerId?: string }, + ): Promise { + await this.checkDto(dto, id); + + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, id), + columns: { + id: true, + guestId: true, + guestName: true, + guestPhone: true, + }, + }); + + if (!order) { + throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); + } + + const { + tableNumber, + restaurantId, + delayedTo, + note, + guestPhone, + guestsAmount, + type, + paymentMethodId, + } = dto; + + let guestName = + dto.guestName && dto.guestName.length > 0 ? dto.guestName : null; + + const guest = await this.guestsService.findByPhoneNumber( + guestPhone ?? order.guestPhone, + ); + + if (!guestName && guest) { + guestName = guest.name; + } + + const updatedOrder = await this.repository.update( + id, + { + ...(tableNumber ? { tableNumber } : {}), + ...(restaurantId ? { restaurantId } : {}), + ...(delayedTo ? { delayedTo: new Date(delayedTo) } : {}), + ...(note ? { note } : {}), + ...(guest ? { guestId: guest.id } : {}), + ...(guestName ? { guestName } : {}), + ...(guestPhone ? { guestPhone } : {}), + ...(guestsAmount ? { guestsAmount } : {}), + ...(type ? { type } : {}), + ...(paymentMethodId ? { paymentMethodId } : {}), + }, + { + workerId: opts?.workerId, + }, + ); + + const updatedOrderEntity = await this.findById(updatedOrder.id); + + // Notify users + await this.ordersQueueProducer.update({ + orderId: id, + }); + + return updatedOrderEntity; + } + + public async getTotalCount(filters?: IFilters): Promise { + const query = this.pg + .select({ + value: count(), + }) + .from(orders); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(orders, filters)); + } + + return await query.then((res) => res[0].value); + } + + public async findById(id: string): Promise { + const order = await this.repository.findById(id); + + if (!order) { + throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); + } + + return order; + } +} diff --git a/src/orders/@queue/dto/crud-update.job.ts b/src/orders/@queue/dto/crud-update.job.ts new file mode 100644 index 0000000..90c0415 --- /dev/null +++ b/src/orders/@queue/dto/crud-update.job.ts @@ -0,0 +1,9 @@ +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; + +export class OrderCrudUpdateJobDto { + orderId: string; +} + +export class OrderDishCrudUpdateJobDto { + orderDish: Omit; +} diff --git a/src/orders/@queue/dto/new-order-at-kitchen.dto.ts b/src/orders/@queue/dto/new-order-at-kitchen.dto.ts new file mode 100644 index 0000000..e215bd6 --- /dev/null +++ b/src/orders/@queue/dto/new-order-at-kitchen.dto.ts @@ -0,0 +1,3 @@ +export class NewOrderAtKitchenJobDto { + orderId: string; +} diff --git a/src/orders/@queue/dto/new-order.job.ts b/src/orders/@queue/dto/new-order.job.ts new file mode 100644 index 0000000..4bc2e0a --- /dev/null +++ b/src/orders/@queue/dto/new-order.job.ts @@ -0,0 +1,3 @@ +export class NewOrderJobDto { + orderId: string; +} diff --git a/src/orders/@queue/index.ts b/src/orders/@queue/index.ts new file mode 100644 index 0000000..2d12144 --- /dev/null +++ b/src/orders/@queue/index.ts @@ -0,0 +1,8 @@ +export const ORDERS_QUEUE = "orders"; + +export enum OrderQueueJobName { + UPDATE = "crud-update", + DISH_UPDATE = "dish-update", + NEW_ORDER = "new-order", + NEW_ORDER_AT_KITCHEN = "new-order-at-kitchen", +} diff --git a/src/orders/@queue/orders-queue.module.ts b/src/orders/@queue/orders-queue.module.ts new file mode 100644 index 0000000..7184f7b --- /dev/null +++ b/src/orders/@queue/orders-queue.module.ts @@ -0,0 +1,25 @@ +import { BullModule } from "@nestjs/bullmq"; +import { forwardRef, Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { SnapshotsModule } from "src/@base/snapshots/snapshots.module"; +import { SocketModule } from "src/@socket/socket.module"; +import { ORDERS_QUEUE } from "src/orders/@queue"; +import { OrdersQueueProcessor } from "src/orders/@queue/orders-queue.processor"; +import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; +import { OrdersSocketNotifier } from "src/orders/@queue/services/orders-socket-notifier.service"; +import { OrdersModule } from "src/orders/orders.module"; + +@Module({ + imports: [ + SocketModule, + DrizzleModule, + SnapshotsModule, + forwardRef(() => OrdersModule), + BullModule.registerQueue({ + name: ORDERS_QUEUE, + }), + ], + providers: [OrdersQueueProcessor, OrdersQueueProducer, OrdersSocketNotifier], + exports: [OrdersQueueProducer], +}) +export class OrdersQueueModule {} diff --git a/src/orders/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts new file mode 100644 index 0000000..b5fa5a1 --- /dev/null +++ b/src/orders/@queue/orders-queue.processor.ts @@ -0,0 +1,82 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Inject, Logger } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { Job } from "bullmq"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { OrdersRepository } from "src/orders/@/repositories/orders.repository"; +import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; +import { + OrderCrudUpdateJobDto, + OrderDishCrudUpdateJobDto, +} from "src/orders/@queue/dto/crud-update.job"; +import { NewOrderAtKitchenJobDto } from "src/orders/@queue/dto/new-order-at-kitchen.dto"; +import { NewOrderJobDto } from "src/orders/@queue/dto/new-order.job"; +import { OrdersSocketNotifier } from "src/orders/@queue/services/orders-socket-notifier.service"; + +@Processor(ORDERS_QUEUE, {}) +export class OrdersQueueProcessor extends WorkerHost { + private readonly logger = new Logger(OrdersQueueProcessor.name); + + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly ordersSocketNotifier: OrdersSocketNotifier, + private readonly ordersRepository: OrdersRepository, + ) { + super(); + } + + /** + * Just a bullmq processor + */ + async process(job: Job) { + const { name, data } = job; + + try { + switch (name) { + case OrderQueueJobName.UPDATE: { + await this.update(data as OrderCrudUpdateJobDto); + break; + } + + case OrderQueueJobName.DISH_UPDATE: { + await this.update({ + orderId: (data as OrderDishCrudUpdateJobDto).orderDish.orderId, + }); + break; + } + + case OrderQueueJobName.NEW_ORDER: { + await this.newOrder(data as NewOrderJobDto); + break; + } + + case OrderQueueJobName.NEW_ORDER_AT_KITCHEN: { + await this.newOrderAtKitchen(data as NewOrderAtKitchenJobDto); + break; + } + + default: { + throw new Error(`Unknown job name`); + } + } + } catch (error) { + this.logger.error(`Failed to process ${name} job`, error); + + throw error; + } + } + + private async update(data: OrderCrudUpdateJobDto) { + await this.ordersSocketNotifier.handleUpdate(data.orderId); + } + + private async newOrder(data: NewOrderJobDto) { + await this.ordersSocketNotifier.handleCreation(data.orderId); + } + + private async newOrderAtKitchen(data: NewOrderAtKitchenJobDto) { + await this.ordersSocketNotifier.handleUpdate(data.orderId); + } +} diff --git a/src/orders/@queue/orders-queue.producer.ts b/src/orders/@queue/orders-queue.producer.ts new file mode 100644 index 0000000..be969a9 --- /dev/null +++ b/src/orders/@queue/orders-queue.producer.ts @@ -0,0 +1,55 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable, Logger } from "@nestjs/common"; +import { JobsOptions, Queue } from "bullmq"; +import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; +import { + OrderCrudUpdateJobDto, + OrderDishCrudUpdateJobDto, +} from "src/orders/@queue/dto/crud-update.job"; + +@Injectable() +export class OrdersQueueProducer { + private readonly logger = new Logger(OrdersQueueProducer.name); + + constructor( + @InjectQueue(ORDERS_QUEUE) + private readonly queue: Queue, + ) {} + + private async addJob(name: OrderQueueJobName, data: any, opts?: JobsOptions) { + try { + return await this.queue.add(name, data, opts); + } catch (error) { + this.logger.error(`Failed to add ${name} job to queue:`, error); + throw error; + } + } + + /** + * When order is: created, updated, removed + */ + public async update(payload: OrderCrudUpdateJobDto) { + return this.addJob(OrderQueueJobName.UPDATE, payload); + } + + /** + * When order dish is: created, updated, removed + */ + public async dishUpdate(payload: OrderDishCrudUpdateJobDto) { + return this.addJob(OrderQueueJobName.DISH_UPDATE, payload); + } + + /** + * When order is: created + */ + public async newOrder(orderId: string) { + return this.addJob(OrderQueueJobName.NEW_ORDER, { orderId }); + } + + /** + * When order is: created at kitchen + */ + public async newOrderAtKitchen(orderId: string) { + return this.addJob(OrderQueueJobName.NEW_ORDER_AT_KITCHEN, { orderId }); + } +} diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts new file mode 100644 index 0000000..8ccc344 --- /dev/null +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -0,0 +1,179 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { SocketService } from "src/@socket/socket.service"; +import { + GatewayClient, + SocketEventType, + SocketRevalidateOrderEvent, +} from "src/@socket/socket.types"; +import { PG_CONNECTION } from "src/constants"; + +@Injectable() +export class OrdersSocketNotifier { + private readonly logger = new Logger(OrdersSocketNotifier.name); + + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly socketService: SocketService, + ) {} + + private async _getOrderRelatedClients(orderId: string) { + // We should return only clients that are related somehow to the order + // For example: SYSTEM_ADMIN, CHIEF_ADMIN will be always notifies + // And for example OWNER will be notified only if he owns restaurant + + const order = await this.pg.query.orders.findFirst({ + where: (orders, { and, eq }) => + and( + eq(orders.id, orderId), + eq(orders.isRemoved, false), + eq(orders.isArchived, false), + ), + columns: {}, + with: { + restaurant: { + columns: {}, + with: { + owner: { + columns: { + id: true, + }, + }, + workersToRestaurants: { + columns: { + workerId: true, + }, + }, + }, + }, + }, + }); + + const alwaysWorkers = await this.pg.query.workers.findMany({ + where: (workers, { eq, and, inArray }) => + and( + eq(workers.isBlocked, false), + inArray(workers.role, ["SYSTEM_ADMIN", "CHIEF_ADMIN", "DISPATCHER"]), + ), + columns: { + id: true, + }, + }); + + // Ids of workers that related to the order + const orderWorkerIdsSet = new Set(); + + if (order) { + if (order.restaurant?.owner?.id) { + orderWorkerIdsSet.add(order.restaurant.owner.id); + } + + if (order.restaurant?.workersToRestaurants) { + order.restaurant.workersToRestaurants.forEach((workerToRestaurant) => { + orderWorkerIdsSet.add(workerToRestaurant.workerId); + }); + } + } + + if (alwaysWorkers) { + alwaysWorkers.forEach((worker) => { + orderWorkerIdsSet.add(worker.id); + }); + } + + const clients = await this.socketService.getClients(); + + return clients.filter((client) => { + return orderWorkerIdsSet.has(client.workerId); + }); + } + + /** + * ! WE SHOULD NOTIFY USERS ONLY IF ORDER HAVE CHANGED DATA + * ! (needs to be implemented before calling that method) + * @param orderId + */ + public async handleUpdate(orderId: string) { + const recipients = await this._getOrderRelatedClients(orderId); + const pathnames = await this.socketService.getCurrentPathnames(); + + const clientIdToPathnameMap = new Map( + Object.entries(pathnames).map(([clientId, pathname]) => [ + clientId, + pathname, + ]), + ); + + const messages: { + recipient: GatewayClient; + event: SocketEventType; + data: any; + }[] = []; + + recipients.forEach((recipient) => { + const pathname = clientIdToPathnameMap.get(recipient.clientId); + + if (!pathname) return; + + if (pathname.includes("/orders") && pathname.includes(orderId)) { + messages.push({ + recipient, + event: SocketEventType.REVALIDATE_ORDER_PAGE, + data: { + orderId, + } satisfies SocketRevalidateOrderEvent, + }); + } else if (pathname.endsWith("/orders/dispatcher")) { + messages.push({ + recipient, + event: SocketEventType.REVALIDATE_DISPATCHER_ORDERS_PAGE, + data: null, + }); + } else if (pathname.endsWith("/orders/kitchen")) { + messages.push({ + recipient, + event: SocketEventType.REVALIDATE_KITCHENER_ORDERS_PAGE, + data: null, + }); + } + }); + + await this.socketService.emit(messages); + } + + public async handleCreation(orderId: string) { + const recipients = await this._getOrderRelatedClients(orderId); + const pathnames = await this.socketService.getCurrentPathnames(); + + const clientIdToPathnameMap = new Map( + Object.entries(pathnames).map(([clientId, pathname]) => [ + clientId, + pathname, + ]), + ); + + const messages: { + recipient: GatewayClient; + event: SocketEventType; + data: any; + }[] = []; + + recipients.forEach((recipient) => { + const pathname = clientIdToPathnameMap.get(recipient.clientId); + + if (!pathname) return; + + if (pathname.endsWith("/orders/dispatcher")) { + messages.push({ + recipient, + event: SocketEventType.REVALIDATE_DISPATCHER_ORDERS_PAGE, + data: null, + }); + } + }); + + await this.socketService.emit(messages); + } +} diff --git a/src/orders/dispatcher/dispatcher-orders.controller.ts b/src/orders/dispatcher/dispatcher-orders.controller.ts new file mode 100644 index 0000000..67c6ed6 --- /dev/null +++ b/src/orders/dispatcher/dispatcher-orders.controller.ts @@ -0,0 +1,117 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { CursorParams, ICursor } from "@core/decorators/cursor.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Get, Query } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { OrderTypeEnum } from "@postgress-db/schema/order-enums"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders.service"; +import { DispatcherOrdersPaginatedEntity } from "src/orders/dispatcher/entities/dispatcher-orders-paginated.entity"; + +@Controller("dispatcher/orders", { + tags: ["dispatcher"], +}) +export class DispatcherOrdersController { + constructor( + private readonly dispatcherOrdersService: DispatcherOrdersService, + ) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(DispatcherOrdersPaginatedEntity) + @ApiOperation({ + summary: "Gets orders for dispatcher", + }) + @ApiOkResponse({ + description: "Orders have been successfully fetched", + type: DispatcherOrdersPaginatedEntity, + }) + async findMany( + @CursorParams() cursor: ICursor, + @Query("type") type?: string, + ): Promise { + const data = await this.dispatcherOrdersService.findMany({ + cursor, + type: + type !== "undefined" && type !== "all" + ? (type as OrderTypeEnum) + : undefined, + }); + + return { + data, + meta: { + offset: 0, + size: 10, + page: 1, + total: 10, + }, + }; + } + + @EnableAuditLog({ onlyErrors: true }) + @Get("attention-required") + @Serializable(DispatcherOrdersPaginatedEntity) + @ApiOperation({ + summary: "Gets attention required orders for dispatcher", + description: + "Returns orders that need dispatcher's attention - orders with pending dishes or without assigned restaurant", + }) + @ApiOkResponse({ + description: "Attention required orders have been successfully fetched", + type: DispatcherOrdersPaginatedEntity, + }) + async findManyAttentionRequired( + @Query("type") type?: string, + ): Promise { + const data = await this.dispatcherOrdersService.findManyAttentionRequired({ + type: + type !== "undefined" && type !== "all" + ? (type as OrderTypeEnum) + : undefined, + }); + + return { + data, + meta: { + offset: 0, + size: 10, + page: 1, + total: 10, + }, + }; + } + + @EnableAuditLog({ onlyErrors: true }) + @Get("delayed") + @Serializable(DispatcherOrdersPaginatedEntity) + @ApiOperation({ + summary: "Gets delayed orders for dispatcher", + description: + "Returns orders that have been delayed and their delayed time is in the future", + }) + @ApiOkResponse({ + description: "Delayed orders have been successfully fetched", + type: DispatcherOrdersPaginatedEntity, + }) + async findManyDelayed( + @Query("type") type?: string, + ): Promise { + const data = await this.dispatcherOrdersService.findManyDelayed({ + type: + type !== "undefined" && type !== "all" + ? (type as OrderTypeEnum) + : undefined, + }); + + return { + data, + meta: { + offset: 0, + size: 10, + page: 1, + total: 10, + }, + }; + } +} diff --git a/src/orders/dispatcher/dispatcher-orders.service.ts b/src/orders/dispatcher/dispatcher-orders.service.ts new file mode 100644 index 0000000..831dc1f --- /dev/null +++ b/src/orders/dispatcher/dispatcher-orders.service.ts @@ -0,0 +1,187 @@ +import { ICursor } from "@core/decorators/cursor.decorator"; +import { Inject, Injectable } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { orderDishes } from "@postgress-db/schema/order-dishes"; +import { OrderTypeEnum } from "@postgress-db/schema/order-enums"; +import { addDays } from "date-fns"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { OrdersRepository } from "src/orders/@/repositories/orders.repository"; +import { DispatcherOrderEntity } from "src/orders/dispatcher/entities/dispatcher-order.entity"; + +@Injectable() +export class DispatcherOrdersService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly ordersRepository: OrdersRepository, + ) {} + + async findMany(options?: { + cursor?: ICursor; + type?: OrderTypeEnum; + restaurantId?: string; + }): Promise { + const { cursor, type, restaurantId } = options ?? {}; + + const fetchedOrders = await this.pg.query.orders.findMany({ + where: (orders, { eq, and, lt, isNull, or, isNotNull }) => + and( + // Exclude archived orders + eq(orders.isArchived, false), + // Exclude removed orders + eq(orders.isRemoved, false), + // Exclude pending delayed orders + or( + and( + isNotNull(orders.delayedTo), + lt(orders.delayedTo, addDays(new Date(), 1)), + ), + isNull(orders.delayedTo), + ), + // Cursor pagination + cursor?.cursorId + ? lt(orders.createdAt, new Date(cursor.cursorId)) + : undefined, + // Filter by type + !!type ? eq(orders.type, type) : undefined, + // Filter by restaurantId + !!restaurantId ? eq(orders.restaurantId, restaurantId) : undefined, + ), + with: { + // Restaurant for restaurantName + restaurant: { + columns: { + name: true, + }, + }, + // Order dishes for statuses + orderDishes: { + where: (orderDishes, { eq }) => eq(orderDishes.isRemoved, false), + columns: { + status: true, + }, + }, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + limit: cursor?.limit ?? 100, + }); + + return this.ordersRepository.attachRestaurantsName(fetchedOrders); + } + + async findManyAttentionRequired(options?: { + type?: OrderTypeEnum; + restaurantId?: string; + }) { + const { type, restaurantId } = options ?? {}; + + const fetchedOrders = await this.pg.query.orders.findMany({ + where: ( + orders, + { eq, and, lt, or, isNotNull, isNull, notInArray, exists }, + ) => + and( + // Filter by restaurantId + !!restaurantId ? eq(orders.restaurantId, restaurantId) : undefined, + // Filter by type + !!type ? eq(orders.type, type) : undefined, + // Exclude archived orders + eq(orders.isArchived, false), + // Exclude cancelled and completed orders + notInArray(orders.status, ["cancelled", "completed"]), + // Check if the order is delayed and the delay time is in the past + or( + // If restaurant is not set attention is still required even if the order is delayed + isNull(orders.restaurantId), + and( + isNotNull(orders.delayedTo), + lt(orders.delayedTo, addDays(new Date(), 1)), + ), + isNull(orders.delayedTo), + ), + or( + // If the restaurant is not set + isNull(orders.restaurantId), + // If some dishes are pending + exists( + this.pg + .select({ id: orderDishes.id }) + .from(orderDishes) + .where( + and( + eq(orderDishes.orderId, orders.id), + eq(orderDishes.status, "pending"), + eq(orderDishes.isRemoved, false), + ), + ), + ), + ), + ), + with: { + // Restaurant for restaurantName + restaurant: { + columns: { + name: true, + }, + }, + // Order dishes for statuses + orderDishes: { + where: (orderDishes, { eq }) => eq(orderDishes.isRemoved, false), + columns: { + status: true, + }, + }, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + limit: 100, + }); + + return this.ordersRepository.attachRestaurantsName(fetchedOrders); + } + + async findManyDelayed(options?: { + type?: OrderTypeEnum; + restaurantId?: string; + }) { + const { type, restaurantId } = options ?? {}; + + const fetchedOrders = await this.pg.query.orders.findMany({ + where: (orders, { eq, and, gt, isNotNull }) => + and( + // Filter by restaurantId + !!restaurantId ? eq(orders.restaurantId, restaurantId) : undefined, + // Exclude archived orders + eq(orders.isArchived, false), + // Exclude removed orders + eq(orders.isRemoved, false), + // Delayed orders condition + and( + isNotNull(orders.delayedTo), + gt(orders.delayedTo, addDays(new Date(), 1)), + ), + // Filter by type + !!type ? eq(orders.type, type) : undefined, + ), + with: { + // Restaurant for restaurantName + restaurant: { + columns: { + name: true, + }, + }, + // Order dishes for statuses + orderDishes: { + where: (orderDishes, { eq }) => eq(orderDishes.isRemoved, false), + columns: { + status: true, + }, + }, + }, + orderBy: (orders, { asc }) => [asc(orders.delayedTo)], + limit: 100, + }); + + return this.ordersRepository.attachRestaurantsName(fetchedOrders); + } +} diff --git a/src/orders/dispatcher/entities/dispatcher-order-dish.entity.ts b/src/orders/dispatcher/entities/dispatcher-order-dish.entity.ts new file mode 100644 index 0000000..442827f --- /dev/null +++ b/src/orders/dispatcher/entities/dispatcher-order-dish.entity.ts @@ -0,0 +1,6 @@ +import { PickType } from "@nestjs/swagger"; +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; + +export class DispatcherOrderDishEntity extends PickType(OrderDishEntity, [ + "status", +]) {} diff --git a/src/orders/dispatcher/entities/dispatcher-order.entity.ts b/src/orders/dispatcher/entities/dispatcher-order.entity.ts new file mode 100644 index 0000000..ecba696 --- /dev/null +++ b/src/orders/dispatcher/entities/dispatcher-order.entity.ts @@ -0,0 +1,19 @@ +import { ApiProperty, OmitType } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { DispatcherOrderDishEntity } from "src/orders/dispatcher/entities/dispatcher-order-dish.entity"; + +export class DispatcherOrderEntity extends OmitType(OrderEntity, [ + "orderDishes", + "isRemoved", + "removedAt", + "isHiddenForGuest", +]) { + @Expose() + @ApiProperty({ + description: "Order dishes", + type: [DispatcherOrderDishEntity], + }) + @Type(() => DispatcherOrderDishEntity) + orderDishes: DispatcherOrderDishEntity[]; +} diff --git a/src/orders/dispatcher/entities/dispatcher-orders-paginated.entity.ts b/src/orders/dispatcher/entities/dispatcher-orders-paginated.entity.ts new file mode 100644 index 0000000..373c95b --- /dev/null +++ b/src/orders/dispatcher/entities/dispatcher-orders-paginated.entity.ts @@ -0,0 +1,14 @@ +import { PaginationResponseDto } from "@core/dto/pagination-response.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { DispatcherOrderEntity } from "src/orders/dispatcher/entities/dispatcher-order.entity"; + +export class DispatcherOrdersPaginatedEntity extends PaginationResponseDto { + @Expose() + @ApiProperty({ + description: "Array of orders", + type: [DispatcherOrderEntity], + }) + @Type(() => DispatcherOrderEntity) + data: DispatcherOrderEntity[]; +} diff --git a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts new file mode 100644 index 0000000..1a19a14 --- /dev/null +++ b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts @@ -0,0 +1,62 @@ +import { IsBoolean, IsString, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, IntersectionType, PickType } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { DishEntity } from "src/dishes/@/entities/dish.entity"; +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; + +export class KitchenerOrderDishWorkshopEntity { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Workshop id", + example: "123e4567-e89b-12d3-a456-426614174000", + }) + id: string; + + @Expose() + @IsString() + @ApiProperty({ + description: "Workshop name", + example: "Kitchen", + }) + name: string; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Is my workshop", + example: false, + }) + isMyWorkshop: boolean; +} + +export class KitchenerOrderDishEntity extends IntersectionType( + PickType(OrderDishEntity, [ + "id", + "status", + "name", + "quantity", + "quantityReturned", + "isAdditional", + "modifiers", + "cookingAt", + "readyAt", + ]), + PickType(DishEntity, ["cookingTimeInMin"]), +) { + @Expose() + @ApiProperty({ + description: "Workshops", + type: [KitchenerOrderDishWorkshopEntity], + }) + @Type(() => KitchenerOrderDishWorkshopEntity) + workshops: KitchenerOrderDishWorkshopEntity[]; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Is ready in time", + example: false, + }) + isReadyOnTime: boolean; +} diff --git a/src/orders/kitchener/entities/kitchener-order.entity.ts b/src/orders/kitchener/entities/kitchener-order.entity.ts new file mode 100644 index 0000000..5763750 --- /dev/null +++ b/src/orders/kitchener/entities/kitchener-order.entity.ts @@ -0,0 +1,27 @@ +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { KitchenerOrderDishEntity } from "src/orders/kitchener/entities/kitchener-order-dish.entity"; + +export class KitchenerOrderEntity extends PickType(OrderEntity, [ + "id", + "status", + "number", + "tableNumber", + "from", + "type", + "note", + "guestsAmount", + "createdAt", + "updatedAt", + "cookingAt", + "delayedTo", +]) { + @Expose() + @ApiProperty({ + description: "Order dishes", + type: [KitchenerOrderDishEntity], + }) + @Type(() => KitchenerOrderDishEntity) + orderDishes: KitchenerOrderDishEntity[]; +} diff --git a/src/orders/kitchener/kitchener-order-actions.service.ts b/src/orders/kitchener/kitchener-order-actions.service.ts new file mode 100644 index 0000000..bcb4549 --- /dev/null +++ b/src/orders/kitchener/kitchener-order-actions.service.ts @@ -0,0 +1,92 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { OrderDishesRepository } from "src/orders/@/repositories/order-dishes.repository"; +import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; + +@Injectable() +export class KitchenerOrderActionsService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly ordersProducer: OrdersQueueProducer, + private readonly repository: OrderDishesRepository, + ) {} + + public async markDishAsReady( + orderDishId: string, + opts?: { worker?: RequestWorker }, + ) { + const orderDish = await this.pg.query.orderDishes.findFirst({ + where: (orderDishes, { eq }) => eq(orderDishes.id, orderDishId), + with: { + order: { + columns: { + restaurantId: true, + }, + }, + }, + }); + + if (opts?.worker && orderDish?.order.restaurantId) { + const { worker } = opts; + + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + } + // Check if owner has access to restaurant + else if ( + worker.role === "OWNER" && + !worker.ownedRestaurants.some( + (r) => r.id === orderDish.order.restaurantId, + ) + ) { + throw new ForbiddenException( + "errors.order-dishes.cant-force-ready-dish", + ); + } + // Restrict to restaurant scope admins + else if ( + worker.role === "ADMIN" && + !worker.workersToRestaurants.some( + (r) => r.restaurantId === orderDish.order.restaurantId, + ) + ) { + throw new ForbiddenException( + "errors.order-dishes.cant-force-ready-dish", + ); + } + } + + if (!orderDish) { + throw new NotFoundException("errors.order-dishes.order-dish-not-found"); + } + + if (orderDish.status !== "cooking") { + throw new BadRequestException( + "errors.order-dishes.cant-force-not-cooking-dish", + ); + } + + const updatedOrderDish = await this.repository.update( + orderDishId, + { + status: "ready", + readyAt: new Date(), + }, + { + workerId: opts?.worker?.id, + }, + ); + + await this.ordersProducer.dishUpdate({ + orderDish: updatedOrderDish, + }); + + return updatedOrderDish; + } +} diff --git a/src/orders/kitchener/kitchener-orders.controller.ts b/src/orders/kitchener/kitchener-orders.controller.ts new file mode 100644 index 0000000..5de39f7 --- /dev/null +++ b/src/orders/kitchener/kitchener-orders.controller.ts @@ -0,0 +1,77 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { Get, Param, Post } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { KitchenerOrderEntity } from "src/orders/kitchener/entities/kitchener-order.entity"; +import { KitchenerOrderActionsService } from "src/orders/kitchener/kitchener-order-actions.service"; +import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.service"; + +@Controller("kitchener/orders", { + tags: ["kitchener"], +}) +export class KitchenerOrdersController { + constructor( + private readonly kitchenerOrdersService: KitchenerOrdersService, + private readonly kitchenerOrderActionsService: KitchenerOrderActionsService, + ) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(KitchenerOrderEntity) + @ApiOperation({ + summary: "Gets orders for kitchener", + }) + @ApiOkResponse({ + description: "Orders have been successfully fetched", + type: [KitchenerOrderEntity], + }) + async findMany(@Worker() worker: RequestWorker) { + const data = await this.kitchenerOrdersService.findMany({ + worker, + }); + + return data; + } + + @EnableAuditLog({ onlyErrors: true }) + @Get(":orderId") + @Serializable(KitchenerOrderEntity) + @ApiOperation({ + summary: "Gets order for kitchener", + }) + @ApiOkResponse({ + description: "Order has been successfully fetched", + }) + async findOne( + @Param("orderId") orderId: string, + @Worker() worker: RequestWorker, + ) { + return this.kitchenerOrdersService.findOne(orderId, { + worker, + }); + } + + @EnableAuditLog() + @Post(":orderId/dishes/:orderDishId/ready") + @ApiOperation({ + description: "Marks order dish as ready", + }) + @ApiOkResponse({ + description: "Order dish has been successfully marked as ready", + }) + async markOrderDishAsReady( + @Param("orderId") orderId: string, + @Param("orderDishId") orderDishId: string, + @Worker() worker: RequestWorker, + ) { + // TODO: restrict access to kitchener workers + await this.kitchenerOrderActionsService.markDishAsReady(orderDishId, { + worker, + }); + + return true; + } +} diff --git a/src/orders/kitchener/kitchener-orders.service.ts b/src/orders/kitchener/kitchener-orders.service.ts new file mode 100644 index 0000000..eeee2c6 --- /dev/null +++ b/src/orders/kitchener/kitchener-orders.service.ts @@ -0,0 +1,488 @@ +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { IOrderDish, orderDishes } from "@postgress-db/schema/order-dishes"; +import { IOrder, orders } from "@postgress-db/schema/orders"; +import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; +import { IWorker } from "@postgress-db/schema/workers"; +import { differenceInMinutes } from "date-fns"; +import { and, eq, exists, gt, inArray, or, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { KitchenerOrderEntity } from "src/orders/kitchener/entities/kitchener-order.entity"; + +@Injectable() +export class KitchenerOrdersService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + /** + * Get all workshop ids for a worker + * @param workerId - The id of the worker + * @returns An array of workshop ids + */ + private async _getWorkerWorkshops( + worker: Pick, + ): Promise<{ id: string; name: string }[] | undefined> { + // System admin and chief admin can see all workshops + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + return undefined; + } + + const workerWorkshops = await this.pg.query.workshopWorkers.findMany({ + where: (workshopWorkers, { eq }) => + eq(workshopWorkers.workerId, worker.id), + with: { + workshop: { + columns: { + isEnabled: true, + name: true, + }, + }, + }, + columns: { + workshopId: true, + }, + }); + + return workerWorkshops + .filter((ww) => !!ww.workshop && ww.workshop.isEnabled) + .map((ww) => ({ + id: ww.workshopId, + name: ww.workshop.name, + })); + } + + private getIsReadyOnTime({ + cookingTimeInMin, + cookingAt, + readyAt, + }: { + cookingTimeInMin: number; + cookingAt?: Date | null; + readyAt?: Date | null; + }) { + if (!cookingAt && !readyAt) { + return false; + } + + const differenceInMin = differenceInMinutes( + cookingAt ?? new Date(), + readyAt ?? new Date(), + ); + + return differenceInMin <= cookingTimeInMin; + } + + /** + * Get the where clause for orders + * @param worker - The worker + * @returns The where clause + */ + private _getOrdersWhere(worker: RequestWorker): SQL { + const conditions: SQL[] = [ + // Exclude archived orders + eq(orders.isArchived, false), + // Exclude removed orders + eq(orders.isRemoved, false), + // Include only orders with cooking status + eq(orders.status, "cooking"), + // Have cooking or ready dishes + exists( + this.pg + .select({ + id: orderDishes.id, + }) + .from(orderDishes) + .where( + and( + eq(orderDishes.orderId, orders.id), + or( + eq(orderDishes.status, "cooking"), + eq(orderDishes.status, "ready"), + ), + eq(orderDishes.isRemoved, false), + ), + ), + ), + ]; + + if (worker.role !== "SYSTEM_ADMIN" && worker.role !== "CHIEF_ADMIN") { + // Get ids to which worker has assigned + const restaurantIdsSet = new Set( + worker.workersToRestaurants.map((wtr) => wtr.restaurantId), + ); + + // If worker is owner, add all owned restaurants to the set + if (worker.role === "OWNER") { + worker.ownedRestaurants.forEach((r) => { + restaurantIdsSet.add(r.id); + }); + } + + conditions.push( + inArray(orders.restaurantId, Array.from(restaurantIdsSet)), + ); + } + + return and(...conditions) as SQL; + } + + private _getOrderDishesWhere(worker: RequestWorker): SQL { + worker; + + return and( + // Only not removed + eq(orderDishes.isRemoved, false), + // Only dishes with quantity > 0 + gt(orderDishes.quantity, 0), + // Only dishes with cooking or ready status + inArray(orderDishes.status, ["cooking", "ready"]), + ) as SQL; + } + + /** + * Maps database order data to KitchenerOrderEntity + * @param fetchedOrders - The orders fetched from database + * @param worker - The worker making the request + * @param workerWorkshopsIdsSet - Set of workshop IDs the worker has access to + * @returns Mapped KitchenerOrderEntity array + */ + private _mapOrdersToKitchenerOrderEntities( + fetchedOrders: Array< + Pick< + IOrder, + | "id" + | "status" + | "number" + | "tableNumber" + | "from" + | "type" + | "note" + | "guestsAmount" + | "createdAt" + | "updatedAt" + | "delayedTo" + | "cookingAt" + > & { + orderDishes: Array< + Pick< + IOrderDish, + | "id" + | "status" + | "name" + | "quantity" + | "quantityReturned" + | "isAdditional" + | "cookingAt" + | "readyAt" + > & { + dish: { + cookingTimeInMin: number; + dishesToWorkshops: Array<{ + workshopId: string; + workshop: { + name: string; + }; + }>; + }; + dishModifiersToOrderDishes: Array<{ + dishModifierId: string; + dishModifier: { + name: string; + }; + }>; + } + >; + } + >, + worker: RequestWorker, + workerWorkshopsIdsSet: Set, + ): KitchenerOrderEntity[] { + return fetchedOrders.map( + ({ orderDishes, ...order }) => + ({ + ...order, + orderDishes: orderDishes.map( + ({ dish, dishModifiersToOrderDishes, ...orderDish }) => ({ + ...orderDish, + cookingTimeInMin: dish.cookingTimeInMin, + isReadyOnTime: this.getIsReadyOnTime({ + cookingTimeInMin: dish.cookingTimeInMin, + cookingAt: orderDish.cookingAt, + readyAt: orderDish.readyAt, + }), + modifiers: dishModifiersToOrderDishes.map( + ({ dishModifierId, dishModifier }) => ({ + id: dishModifierId, + name: dishModifier.name, + }), + ), + workshops: dish.dishesToWorkshops.map( + ({ workshopId, workshop }) => + ({ + id: workshopId, + name: workshop.name, + isMyWorkshop: + worker.role === "SYSTEM_ADMIN" ?? + worker.role === "CHIEF_ADMIN" ?? + workerWorkshopsIdsSet.has(workshopId), + }) satisfies KitchenerOrderEntity["orderDishes"][number]["workshops"][number], + ), + }), + ), + }) satisfies KitchenerOrderEntity, + ); + } + + /** + * Find one order + * @param orderId - The id of the order + * @param opts - The options + * @returns The order + */ + public async findOne( + orderId: string, + opts: { + worker: RequestWorker; + }, + ): Promise { + const { worker } = opts; + + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq, and }) => + and( + // Specify order id + eq(orders.id, orderId), + // Default orders where + this._getOrdersWhere(worker), + ), + with: { + orderDishes: { + // Filter + where: this._getOrderDishesWhere(worker), + // Select + columns: { + id: true, + status: true, + name: true, + quantity: true, + quantityReturned: true, + isAdditional: true, + cookingAt: true, + readyAt: true, + }, + with: { + dishModifiersToOrderDishes: { + columns: { + dishModifierId: true, + }, + with: { + dishModifier: { + columns: { + name: true, + }, + }, + }, + }, + dish: { + with: { + dishesToWorkshops: { + // Check if workshop is assigned to restaurant + where: (dishesToWorkshops, { eq, and, exists }) => + and( + exists( + this.pg + .select({ id: restaurantWorkshops.id }) + .from(restaurantWorkshops) + .where( + and( + eq( + restaurantWorkshops.restaurantId, + orders.restaurantId, + ), + eq( + restaurantWorkshops.id, + dishesToWorkshops.workshopId, + ), + ), + ), + ), + ), + columns: { + workshopId: true, + }, + with: { + workshop: { + columns: { + name: true, + }, + }, + }, + }, + }, + columns: { + cookingTimeInMin: true, + }, + }, + }, + }, + }, + columns: { + id: true, + status: true, + number: true, + tableNumber: true, + from: true, + type: true, + note: true, + guestsAmount: true, + createdAt: true, + updatedAt: true, + delayedTo: true, + cookingAt: true, + }, + }); + + if (!order) { + return null; + } + + const workerWorkshops = await this._getWorkerWorkshops({ + id: worker.id, + role: worker.role, + }); + + const workerWorkshopsIdsSet = new Set( + workerWorkshops?.map((ww) => ww.id) ?? [], + ); + + const mappedOrders = this._mapOrdersToKitchenerOrderEntities( + [order], + worker, + workerWorkshopsIdsSet, + ); + + return mappedOrders[0] ?? null; + } + + /** + * Finds all orders for a kitchener worker + * @param opts - The options + * @returns An array of orders + */ + public async findMany(opts: { + worker: RequestWorker; + }): Promise { + const { worker } = opts; + const workerId = worker.id; + + const workerWorkshops = await this._getWorkerWorkshops({ + id: workerId, + role: worker.role, + }); + + const workerWorkshopsIdsSet = new Set( + workerWorkshops?.map((ww) => ww.id) ?? [], + ); + + const fetchedOrders = await this.pg.query.orders.findMany({ + where: this._getOrdersWhere(worker), + with: { + orderDishes: { + // Filter + where: this._getOrderDishesWhere(worker), + // Select + columns: { + id: true, + status: true, + name: true, + quantity: true, + quantityReturned: true, + isAdditional: true, + cookingAt: true, + readyAt: true, + }, + with: { + dishModifiersToOrderDishes: { + columns: { + dishModifierId: true, + }, + with: { + dishModifier: { + columns: { + name: true, + }, + }, + }, + }, + dish: { + with: { + dishesToWorkshops: { + // Check if workshop is assigned to restaurant + where: (dishesToWorkshops, { eq, and, exists }) => + and( + exists( + this.pg + .select({ id: restaurantWorkshops.id }) + .from(restaurantWorkshops) + .where( + and( + eq( + restaurantWorkshops.restaurantId, + orders.restaurantId, + ), + eq( + restaurantWorkshops.id, + dishesToWorkshops.workshopId, + ), + ), + ), + ), + ), + columns: { + workshopId: true, + }, + with: { + workshop: { + columns: { + name: true, + }, + }, + }, + }, + }, + columns: { + cookingTimeInMin: true, + }, + }, + }, + }, + }, + columns: { + id: true, + status: true, + number: true, + tableNumber: true, + from: true, + type: true, + note: true, + guestsAmount: true, + createdAt: true, + updatedAt: true, + delayedTo: true, + cookingAt: true, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + limit: 100, + }); + + return this._mapOrdersToKitchenerOrderEntities( + fetchedOrders, + worker, + workerWorkshopsIdsSet, + ); + } +} diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts new file mode 100644 index 0000000..64d6fd4 --- /dev/null +++ b/src/orders/orders.module.ts @@ -0,0 +1,65 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { SnapshotsModule } from "src/@base/snapshots/snapshots.module"; +import { GuestsModule } from "src/guests/guests.module"; +import { OrderActionsController } from "src/orders/@/order-actions.controller"; +import { OrderDishesController } from "src/orders/@/order-dishes.controller"; +import { OrderHistoryController } from "src/orders/@/order-history.controller"; +import { OrderMenuController } from "src/orders/@/order-menu.controller"; +import { OrdersController } from "src/orders/@/orders.controller"; +import { OrderDishesRepository } from "src/orders/@/repositories/order-dishes.repository"; +import { OrdersRepository } from "src/orders/@/repositories/orders.repository"; +import { OrderActionsService } from "src/orders/@/services/order-actions.service"; +import { OrderDiscountsService } from "src/orders/@/services/order-discounts.service"; +import { OrderDishesService } from "src/orders/@/services/order-dishes.service"; +import { OrderHistoryService } from "src/orders/@/services/order-history.service"; +import { OrderMenuService } from "src/orders/@/services/order-menu.service"; +import { OrderUpdatersService } from "src/orders/@/services/order-updaters.service"; +import { OrdersService } from "src/orders/@/services/orders.service"; +import { OrdersQueueModule } from "src/orders/@queue/orders-queue.module"; +import { DispatcherOrdersController } from "src/orders/dispatcher/dispatcher-orders.controller"; +import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders.service"; +import { KitchenerOrderActionsService } from "src/orders/kitchener/kitchener-order-actions.service"; +import { KitchenerOrdersController } from "src/orders/kitchener/kitchener-orders.controller"; +import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.service"; +import { TimezonesModule } from "src/timezones/timezones.module"; + +@Module({ + imports: [ + DrizzleModule, + GuestsModule, + OrdersQueueModule, + SnapshotsModule, + TimezonesModule, + ], + providers: [ + OrdersRepository, + OrderDishesRepository, + OrdersService, + DispatcherOrdersService, + KitchenerOrdersService, + OrderDishesService, + OrderMenuService, + OrderUpdatersService, + OrderActionsService, + KitchenerOrderActionsService, + OrderHistoryService, + OrderDiscountsService, + ], + controllers: [ + OrdersController, + OrderMenuController, + OrderDishesController, + DispatcherOrdersController, + KitchenerOrdersController, + OrderActionsController, + OrderHistoryController, + ], + exports: [ + OrdersService, + OrderDishesService, + OrdersRepository, + OrderDishesRepository, + ], +}) +export class OrdersModule {} diff --git a/src/payment-methods/dto/create-payment-method.dto.ts b/src/payment-methods/dto/create-payment-method.dto.ts new file mode 100644 index 0000000..c4cbc9f --- /dev/null +++ b/src/payment-methods/dto/create-payment-method.dto.ts @@ -0,0 +1,25 @@ +import { IsOptional, IsString } from "@i18n-class-validator"; +import { + ApiPropertyOptional, + IntersectionType, + PartialType, + PickType, +} from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { PaymentMethodEntity } from "src/payment-methods/entities/payment-method.entity"; + +export class CreatePaymentMethodDto extends IntersectionType( + PickType(PaymentMethodEntity, ["name", "type", "icon"]), + PartialType(PickType(PaymentMethodEntity, ["secretId", "isActive"])), +) { + @Expose() + @IsOptional() + @IsString() + @ApiPropertyOptional({ + description: "Secret key for payment integration", + example: "live_secretKey", + }) + secretKey: string | null; + + restaurantId: string; +} diff --git a/src/payment-methods/dto/update-payment-method.dto.ts b/src/payment-methods/dto/update-payment-method.dto.ts new file mode 100644 index 0000000..c5cc7b7 --- /dev/null +++ b/src/payment-methods/dto/update-payment-method.dto.ts @@ -0,0 +1,9 @@ +import { IntersectionType, OmitType, PartialType } from "@nestjs/swagger"; + +import { CreatePaymentMethodDto } from "./create-payment-method.dto"; + +export class UpdatePaymentMethodDto extends IntersectionType( + PartialType( + OmitType(CreatePaymentMethodDto, ["secretKey", "type", "secretId"]), + ), +) {} diff --git a/src/payment-methods/entities/payment-method.entity.ts b/src/payment-methods/entities/payment-method.entity.ts new file mode 100644 index 0000000..731c317 --- /dev/null +++ b/src/payment-methods/entities/payment-method.entity.ts @@ -0,0 +1,112 @@ +import { + IsBoolean, + IsEnum, + IsISO8601, + IsOptional, + IsString, + IsUUID, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IPaymentMethod, + paymentMethodIconEnum, + paymentMethodTypeEnum, +} from "@postgress-db/schema/payment-methods"; +import { Expose } from "class-transformer"; + +export class PaymentMethodEntity implements Omit { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the payment method", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the payment method", + example: "Yoo Kassa", + }) + name: string; + + @IsEnum(paymentMethodTypeEnum.enumValues) + @Expose() + @ApiProperty({ + description: "Type of the payment method", + enum: paymentMethodTypeEnum.enumValues, + example: "YOO_KASSA", + }) + type: (typeof paymentMethodTypeEnum.enumValues)[number]; + + @IsEnum(paymentMethodIconEnum.enumValues) + @Expose() + @ApiProperty({ + description: "Icon type for the payment method", + enum: paymentMethodIconEnum.enumValues, + example: "YOO_KASSA", + }) + icon: (typeof paymentMethodIconEnum.enumValues)[number]; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "ID of the restaurant this payment method belongs to", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + restaurantId: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional({ + description: "Secret ID for payment integration", + example: "live_secretId", + }) + secretId: string | null; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the payment method is active", + example: true, + }) + isActive: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the payment method is removed", + example: false, + }) + isRemoved: boolean; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when payment method was created", + example: new Date().toISOString(), + type: Date, + }) + createdAt: Date; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when payment method was last updated", + example: new Date().toISOString(), + type: Date, + }) + updatedAt: Date; + + @IsOptional() + @IsISO8601() + @Expose() + @ApiPropertyOptional({ + description: "Date when payment method was removed", + example: null, + type: Date, + }) + removedAt: Date | null; +} diff --git a/src/payment-methods/payment-methods.controller.ts b/src/payment-methods/payment-methods.controller.ts new file mode 100644 index 0000000..661f698 --- /dev/null +++ b/src/payment-methods/payment-methods.controller.ts @@ -0,0 +1,95 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { Body, Get, Param, Patch, Post } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { CreatePaymentMethodDto } from "src/payment-methods/dto/create-payment-method.dto"; +import { UpdatePaymentMethodDto } from "src/payment-methods/dto/update-payment-method.dto"; +import { PaymentMethodEntity } from "src/payment-methods/entities/payment-method.entity"; +import { PaymentMethodsService } from "src/payment-methods/payment-methods.service"; +import { RestaurantGuard } from "src/restaurants/@/decorators/restaurant-guard.decorator"; + +type RestaurantPaymentMethodsRouteParams = { + id: string; +}; + +@Controller("restaurants/:id/payment-methods", { + tags: ["restaurants"], +}) +export class PaymentMethodsController { + constructor(private readonly paymentMethodsService: PaymentMethodsService) {} + + @RestaurantGuard({ + restaurantId: (req) => + (req.params as RestaurantPaymentMethodsRouteParams).id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(PaymentMethodEntity) + @ApiOperation({ + summary: "Gets payment methods for a restaurant", + }) + @ApiOkResponse({ + description: "Payment methods have been successfully fetched", + type: [PaymentMethodEntity], + }) + async findMany(@Param("id") restaurantId: string) { + return await this.paymentMethodsService.findMany({ + restaurantId, + }); + } + + @RestaurantGuard({ + restaurantId: (req) => + (req.params as RestaurantPaymentMethodsRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Post() + @Serializable(PaymentMethodEntity) + @ApiOperation({ + summary: "Creates a new payment method for a restaurant", + }) + @ApiOkResponse({ + description: "Payment method has been successfully created", + }) + async create( + @Param("id") restaurantId: string, + @Body() payload: CreatePaymentMethodDto, + @Worker() worker: RequestWorker, + ) { + return await this.paymentMethodsService.create( + { + ...payload, + restaurantId, + }, + { + worker, + }, + ); + } + + @RestaurantGuard({ + restaurantId: (req) => + (req.params as RestaurantPaymentMethodsRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Patch(":paymentMethodId") + @Serializable(PaymentMethodEntity) + @ApiOperation({ + summary: "Updates a payment method for a restaurant", + }) + @ApiOkResponse({ + description: "Payment method has been successfully updated", + }) + async update( + @Param("paymentMethodId") paymentMethodId: string, + @Body() payload: UpdatePaymentMethodDto, + ) { + return await this.paymentMethodsService.update(paymentMethodId, payload); + } +} diff --git a/src/payment-methods/payment-methods.module.ts b/src/payment-methods/payment-methods.module.ts new file mode 100644 index 0000000..32b139c --- /dev/null +++ b/src/payment-methods/payment-methods.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { EncryptionModule } from "src/@base/encryption/encryption.module"; +import { PaymentMethodsController } from "src/payment-methods/payment-methods.controller"; +import { PaymentMethodsService } from "src/payment-methods/payment-methods.service"; + +@Module({ + imports: [DrizzleModule, EncryptionModule], + controllers: [PaymentMethodsController], + providers: [PaymentMethodsService], + exports: [PaymentMethodsService], +}) +export class PaymentMethodsModule {} diff --git a/src/payment-methods/payment-methods.service.ts b/src/payment-methods/payment-methods.service.ts new file mode 100644 index 0000000..f67d23c --- /dev/null +++ b/src/payment-methods/payment-methods.service.ts @@ -0,0 +1,130 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { paymentMethods } from "@postgress-db/schema/payment-methods"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { EncryptionService } from "src/@base/encryption/encryption.service"; +import { PG_CONNECTION } from "src/constants"; +import { CreatePaymentMethodDto } from "src/payment-methods/dto/create-payment-method.dto"; +import { UpdatePaymentMethodDto } from "src/payment-methods/dto/update-payment-method.dto"; + +@Injectable() +export class PaymentMethodsService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly encryptionService: EncryptionService, + ) {} + + private async checkAccessRights( + restaurantId: string, + worker?: RequestWorker, + ): Promise { + if (!worker) return; + + // System admin and chief admin have full access + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + return; + } + + const restaurant = await this.pg.query.restaurants.findFirst({ + where: (restaurants, { eq }) => eq(restaurants.id, restaurantId), + columns: { + id: true, + ownerId: true, + }, + }); + + if (!restaurant) { + throw new BadRequestException( + "errors.payment-methods.restaurant-not-found", + ); + } + + switch (worker.role) { + case "OWNER": + if (worker.id !== restaurant.ownerId) { + throw new BadRequestException( + "errors.payment-methods.not-enough-rights", + ); + } + break; + case "ADMIN": + if ( + !worker.workersToRestaurants.some( + (r) => r.restaurantId === restaurant.id, + ) + ) { + throw new BadRequestException( + "errors.payment-methods.not-enough-rights", + ); + } + break; + default: + throw new BadRequestException( + "errors.payment-methods.not-enough-rights", + ); + } + } + + async findMany(options: { restaurantId: string }) { + return await this.pg.query.paymentMethods.findMany({ + where: (paymentMethods, { eq }) => + eq(paymentMethods.restaurantId, options.restaurantId), + }); + } + + async create( + payload: CreatePaymentMethodDto, + opts?: { worker: RequestWorker }, + ) { + const { type, secretId, secretKey, restaurantId } = payload; + + await this.checkAccessRights(restaurantId, opts?.worker); + + if (type === "YOO_KASSA" && (!secretId || !secretKey)) { + throw new BadRequestException( + "errors.payment-methods.secret-id-and-secret-key-are-required", + ); + } + + return await this.pg + .insert(paymentMethods) + .values({ + ...payload, + ...(secretId && + secretKey && { + secretId, + secretKey: await this.encryptionService.encrypt(secretKey), + }), + }) + .returning(); + } + + async update( + id: string, + payload: UpdatePaymentMethodDto, + opts?: { worker: RequestWorker }, + ) { + const paymentMethod = await this.pg.query.paymentMethods.findFirst({ + where: (methods, { eq }) => eq(methods.id, id), + columns: { + restaurantId: true, + }, + }); + + if (!paymentMethod) { + throw new BadRequestException("errors.payment-methods.not-found"); + } + + await this.checkAccessRights(paymentMethod.restaurantId, opts?.worker); + + return await this.pg + .update(paymentMethods) + .set(payload) + .where(eq(paymentMethods.id, id)) + .returning(); + } +} diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts new file mode 100644 index 0000000..d7e6a49 --- /dev/null +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -0,0 +1,233 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { + IPagination, + PaginationParams, +} from "@core/decorators/pagination.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { StringValuePipe } from "@core/pipes/string.pipe"; +import { Body, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { RestaurantGuard } from "src/restaurants/@/decorators/restaurant-guard.decorator"; + +import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; +import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; +import { RestaurantsPaginatedDto } from "../dto/views/get-restaurants.view"; +import { RestaurantEntity } from "../entities/restaurant.entity"; +import { RestaurantsService } from "../services/restaurants.service"; + +type RestaurantRouteParams = { + id: string; +}; + +@Controller("restaurants") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class RestaurantsController { + constructor(private readonly restaurantsService: RestaurantsService) {} + + // TODO: configure custom guard for this endpoint + @EnableAuditLog({ onlyErrors: true }) + @Get() + @ApiOperation({ + summary: "Gets restaurants that created in system", + }) + @Serializable(RestaurantsPaginatedDto) + @ApiOkResponse({ + description: "Restaurants have been successfully fetched", + type: RestaurantsPaginatedDto, + }) + @ApiQuery({ + name: "menuId", + description: "Filter out restaurants that was assigned to a menu", + type: String, + required: false, + }) + @ApiQuery({ + name: "ownerId", + description: "Filter out restaurants by owner id", + type: String, + required: false, + }) + @ApiQuery({ + name: "search", + description: "Search query string", + type: String, + required: false, + }) + @ApiQuery({ + name: "isEnabled", + description: "Filter out restaurants by isEnabled", + type: Boolean, + required: false, + }) + @ApiQuery({ + name: "isClosedForever", + description: "Filter out restaurants by isClosedForever", + type: Boolean, + required: false, + }) + async findAll( + @PaginationParams() pagination: IPagination, + @Worker() worker: RequestWorker, + @Query("search", new StringValuePipe()) search?: string | null, + @Query("menuId", new StringValuePipe()) menuId?: string | null, + @Query("ownerId", new StringValuePipe()) ownerId?: string | null, + @Query("isEnabled", new StringValuePipe()) isEnabled?: string | null, + @Query("isClosedForever", new StringValuePipe()) + isClosedForever?: string | null, + ) { + const total = await this.restaurantsService.getTotalCount({ + menuId, + ownerId, + search, + ...(typeof isEnabled === "string" && { + isEnabled: isEnabled === "true", + }), + ...(typeof isClosedForever === "string" && { + isClosedForever: isClosedForever === "true", + }), + }); + + const data = await this.restaurantsService.findMany({ + pagination, + worker, + menuId, + ownerId, + search, + ...(typeof isEnabled === "string" && { + isEnabled: isEnabled === "true", + }), + ...(typeof isClosedForever === "string" && { + isClosedForever: isClosedForever === "true", + }), + }); + + return { + data, + meta: { + ...pagination, + total, + }, + }; + } + + @EnableAuditLog() + @Post() + @Serializable(RestaurantEntity) + @ApiOperation({ + summary: "Creates a new restaurant", + }) + @ApiCreatedResponse({ + description: "Restaurant has been successfully created", + type: RestaurantEntity, + }) + @ApiForbiddenResponse({ + description: "Action available only for SYSTEM_ADMIN, CHIEF_ADMIN, OWNER", + }) + async create( + @Body() dto: CreateRestaurantDto, + @Worker() worker: RequestWorker, + ): Promise { + if ( + worker.role !== "SYSTEM_ADMIN" && + worker.role !== "CHIEF_ADMIN" && + worker.role !== "OWNER" + ) { + throw new ForbiddenException(); + } + + return await this.restaurantsService.create(dto, { + worker, + }); + } + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) + @Get(":id") + @Serializable(RestaurantEntity) + @ApiOperation({ + summary: "Gets restaurant by id", + }) + @ApiOkResponse({ + description: "Restaurant has been successfully fetched", + type: RestaurantEntity, + }) + @ApiNotFoundResponse({ + description: "Restaurant with this id not found", + }) + async findOne( + @Param("id") id: string, + @Worker() worker: RequestWorker, + ): Promise { + return await this.restaurantsService.findById(id, { + worker, + }); + } + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Patch(":id") + @Serializable(RestaurantEntity) + @ApiOperation({ + summary: "Updates restaurant by id", + }) + @ApiOkResponse({ + description: "Restaurant has been successfully updated", + type: RestaurantEntity, + }) + @ApiNotFoundResponse({ + description: "Restaurant with this id not found", + }) + @ApiForbiddenResponse({ + description: "Action available only for SYSTEM_ADMIN, CHIEF_ADMIN", + }) + async update( + @Param("id") id: string, + @Body() dto: UpdateRestaurantDto, + @Worker() worker: RequestWorker, + ): Promise { + return await this.restaurantsService.update(id, dto, { + worker, + }); + } + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER"], + }) + @EnableAuditLog() + @Delete(":id") + @ApiOperation({ + summary: "Deletes restaurant by id", + }) + @ApiNoContentResponse({ + description: "Restaurant has been successfully deleted", + }) + @ApiNotFoundResponse({ + description: "Restaurant with this id not found", + }) + @ApiForbiddenResponse({ + description: "Action available only for SYSTEM_ADMIN", + }) + async delete(@Param("id") id: string): Promise { + return await this.restaurantsService.delete(id); + } +} diff --git a/src/restaurants/@/decorators/restaurant-guard.decorator.ts b/src/restaurants/@/decorators/restaurant-guard.decorator.ts new file mode 100644 index 0000000..2f3dd20 --- /dev/null +++ b/src/restaurants/@/decorators/restaurant-guard.decorator.ts @@ -0,0 +1,18 @@ +import { Request } from "@core/interfaces/request"; +import { SetMetadata } from "@nestjs/common"; +import { IRoleEnum } from "@postgress-db/schema/workers"; + +export interface RestaurantGuardOptions { + restaurantId: (req: Request) => string; + allow: `${ + | IRoleEnum.KITCHENER + | IRoleEnum.CASHIER + | IRoleEnum.WAITER + | IRoleEnum.ADMIN + | IRoleEnum.OWNER}`[]; +} + +export const RESTAURANT_GUARD_KEY = "restaurantGuard"; + +export const RestaurantGuard = (options: RestaurantGuardOptions) => + SetMetadata(RESTAURANT_GUARD_KEY, options); diff --git a/src/restaurants/@/dto/create-restaurant.dto.ts b/src/restaurants/@/dto/create-restaurant.dto.ts new file mode 100644 index 0000000..a47e6e5 --- /dev/null +++ b/src/restaurants/@/dto/create-restaurant.dto.ts @@ -0,0 +1,16 @@ +import { PickType } from "@nestjs/swagger"; + +import { RestaurantEntity } from "../entities/restaurant.entity"; + +export class CreateRestaurantDto extends PickType(RestaurantEntity, [ + "name", + "legalEntity", + "address", + "latitude", + "longitude", + "timezone", + "isEnabled", + "currency", + "isClosedForever", + "ownerId", +]) {} diff --git a/src/restaurants/@/dto/restaurant-with-hours.dto.ts b/src/restaurants/@/dto/restaurant-with-hours.dto.ts new file mode 100644 index 0000000..15e34c4 --- /dev/null +++ b/src/restaurants/@/dto/restaurant-with-hours.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { RestaurantHoursEntity } from "src/restaurants/hours/entities/restaurant-hours.entity"; + +import { RestaurantEntity } from "../entities/restaurant.entity"; + +export class RestaurantWithHoursDto extends RestaurantEntity { + @Expose() + @ApiProperty({ + description: "Array of restaurant hours", + type: [RestaurantHoursEntity], + }) + @Type(() => RestaurantHoursEntity) + hours: RestaurantHoursEntity[]; +} diff --git a/src/restaurants/dto/update-restaurant.dto.ts b/src/restaurants/@/dto/update-restaurant.dto.ts similarity index 100% rename from src/restaurants/dto/update-restaurant.dto.ts rename to src/restaurants/@/dto/update-restaurant.dto.ts diff --git a/src/restaurants/dto/views/get-restaurants.view.ts b/src/restaurants/@/dto/views/get-restaurants.view.ts similarity index 67% rename from src/restaurants/dto/views/get-restaurants.view.ts rename to src/restaurants/@/dto/views/get-restaurants.view.ts index c452262..27d32d4 100644 --- a/src/restaurants/dto/views/get-restaurants.view.ts +++ b/src/restaurants/@/dto/views/get-restaurants.view.ts @@ -2,14 +2,14 @@ import { PaginationResponseDto } from "@core/dto/pagination-response.entity"; import { ApiProperty } from "@nestjs/swagger"; import { Expose, Type } from "class-transformer"; -import { RestaurantDto } from "../restaurant.dto"; +import { RestaurantEntity } from "../../entities/restaurant.entity"; export class RestaurantsPaginatedDto extends PaginationResponseDto { @Expose() @ApiProperty({ description: "Array of restaurants", - type: [RestaurantDto], + type: [RestaurantEntity], }) - @Type(() => RestaurantDto) - data: RestaurantDto[]; + @Type(() => RestaurantEntity) + data: RestaurantEntity[]; } diff --git a/src/restaurants/dto/restaurant.dto.ts b/src/restaurants/@/entities/restaurant.entity.ts similarity index 58% rename from src/restaurants/dto/restaurant.dto.ts rename to src/restaurants/@/entities/restaurant.entity.ts index b6a94db..5545df4 100644 --- a/src/restaurants/dto/restaurant.dto.ts +++ b/src/restaurants/@/entities/restaurant.entity.ts @@ -1,16 +1,18 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IRestaurant } from "@postgress-db/schema"; -import { Expose } from "class-transformer"; import { IsBoolean, + IsEnum, IsISO8601, IsLatitude, IsOptional, IsString, IsUUID, -} from "class-validator"; +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ZodCurrency } from "@postgress-db/schema/general"; +import { IRestaurant } from "@postgress-db/schema/restaurants"; +import { Expose } from "class-transformer"; -export class RestaurantDto implements IRestaurant { +export class RestaurantEntity implements IRestaurant { @IsUUID() @Expose() @ApiProperty({ @@ -63,6 +65,31 @@ export class RestaurantDto implements IRestaurant { }) longitude: string; + @Expose() + @IsString() + @ApiProperty({ + description: "Timezone of the restaurant", + example: "Europe/Tallinn", + }) + timezone: string; + + @IsEnum(ZodCurrency.Enum) + @Expose() + @ApiProperty({ + description: "Currency of the restaurant", + enum: ZodCurrency.Enum, + example: "EUR", + }) + currency: typeof ZodCurrency._type; + + @IsString() + @Expose() + @ApiProperty({ + description: "Country code of the restaurant", + example: "EE", + }) + countryCode: string; + @IsBoolean() @Expose() @ApiProperty({ @@ -71,6 +98,23 @@ export class RestaurantDto implements IRestaurant { }) isEnabled: boolean; + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is restaurant closed forever", + example: false, + }) + isClosedForever: boolean; + + @IsOptional() + @IsUUID() + @Expose() + @ApiPropertyOptional({ + description: "Owner of the restaurant", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + ownerId: string | null; + @IsISO8601() @Expose() @ApiProperty({ diff --git a/src/restaurants/@/guards/restaurant.guard.ts b/src/restaurants/@/guards/restaurant.guard.ts new file mode 100644 index 0000000..a25dc61 --- /dev/null +++ b/src/restaurants/@/guards/restaurant.guard.ts @@ -0,0 +1,66 @@ +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { Request } from "@core/interfaces/request"; +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { RESTAURANT_GUARD_KEY } from "src/restaurants/@/decorators/restaurant-guard.decorator"; + +@Injectable() +export class RestaurantGuard implements CanActivate { + private readonly logger = new Logger(RestaurantGuard.name); + + constructor(private readonly reflector: Reflector) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest() as Request; + + const options = this.reflector.get( + RESTAURANT_GUARD_KEY, + context.getHandler(), + ); + + if (!options) return true; + + const restaurantId = options.restaurantId(request); + const { worker } = request; + const { allow } = options; + + if (!restaurantId) { + this.logger.error("Restaurant ID is not defined"); + throw new ForbiddenException(); + } + + // We need to have worker in request + if (!worker) { + throw new ForbiddenException(); + } + + // System and chief admin can do anything + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + return true; + } + + // Owner can access their own restaurant + if ( + allow.includes("OWNER") && + worker.role === "OWNER" && + worker.ownedRestaurants.some((r) => r.id === restaurantId) + ) { + return true; + } + + // Handle rest of roles that can access their restaurant + if ( + allow.includes(worker.role) && + worker.workersToRestaurants.some((r) => r.restaurantId === restaurantId) + ) { + return true; + } + + throw new ForbiddenException(); + } +} diff --git a/src/restaurants/@/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts new file mode 100644 index 0000000..0b9af42 --- /dev/null +++ b/src/restaurants/@/services/restaurants.service.ts @@ -0,0 +1,367 @@ +import { IPagination } from "@core/decorators/pagination.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { dishesMenusToRestaurants } from "@postgress-db/schema/dishes-menus"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { and, count, eq, exists, ilike, inArray, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { TimezonesService } from "src/timezones/timezones.service"; + +import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; +import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; +import { RestaurantEntity } from "../entities/restaurant.entity"; + +@Injectable() +export class RestaurantsService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly timezonesService: TimezonesService, + ) {} + + private _buildConditions(options: { + menuId?: string | null; + ownerId?: string | null; + search?: string | null; + isEnabled?: boolean | null; + isClosedForever?: boolean | null; + }) { + const { menuId, ownerId, search, isEnabled, isClosedForever } = options; + + const conditions: SQL[] = []; + + if (menuId && menuId.length > 0) { + conditions.push( + exists( + this.pg + .select({ id: dishesMenusToRestaurants.restaurantId }) + .from(dishesMenusToRestaurants) + .where( + and( + eq(dishesMenusToRestaurants.dishesMenuId, menuId), + eq(dishesMenusToRestaurants.restaurantId, restaurants.id), + ), + ), + ), + ); + } + + if (ownerId && ownerId.length > 0) { + conditions.push(eq(schema.restaurants.ownerId, ownerId)); + } + + if (search && search.length > 0) { + conditions.push(ilike(schema.restaurants.name, `%${search}%`)); + } + + if (typeof isEnabled === "boolean") { + conditions.push(eq(schema.restaurants.isEnabled, isEnabled)); + } + + if (typeof isClosedForever === "boolean") { + conditions.push(eq(schema.restaurants.isClosedForever, isClosedForever)); + } + + return conditions; + } + + /** + * Gets total count of restaurants + * @returns + */ + public async getTotalCount(options: { + menuId?: string | null; + ownerId?: string | null; + search?: string | null; + isEnabled?: boolean | null; + isClosedForever?: boolean | null; + }): Promise { + const { menuId, ownerId, search, isEnabled, isClosedForever } = options; + + const conditions = this._buildConditions({ + menuId, + ownerId, + search, + isEnabled, + isClosedForever, + }); + + const dbQuery = this.pg + .select({ + value: count(), + }) + .from(schema.restaurants); + + if (conditions.length > 0) { + dbQuery.where(and(...conditions)); + } + + const result = await dbQuery; + + return result[0].value; + } + + /** + * Find many restaurants + * @param options + * @returns + */ + public async findMany(options: { + pagination: IPagination; + worker?: RequestWorker; + menuId?: string | null; + ownerId?: string | null; + search?: string | null; + isEnabled?: boolean | null; + isClosedForever?: boolean | null; + }): Promise { + const { + pagination, + worker, + menuId, + ownerId, + search, + isEnabled, + isClosedForever, + } = options; + + const conditions: SQL[] = []; + + if (worker) { + if (worker.role === "OWNER") { + conditions.push(eq(schema.restaurants.ownerId, worker.id)); + } else if ( + worker.role !== "SYSTEM_ADMIN" && + worker.role !== "CHIEF_ADMIN" + ) { + if (worker?.workersToRestaurants.length === 0) { + return []; + } + + conditions.push( + inArray( + schema.restaurants.id, + worker.workersToRestaurants.map((r) => r.restaurantId), + ), + ); + } + } + + conditions.push( + ...this._buildConditions({ + menuId, + ownerId, + search, + isEnabled, + isClosedForever, + }), + ); + + return await this.pg.query.restaurants.findMany({ + ...(conditions.length > 0 + ? { + where: () => and(...conditions), + } + : {}), + limit: pagination.size, + offset: pagination.offset, + }); + } + + /** + * Find one restaurant by id + * @param id + * @returns + */ + public async findById( + id: string, + opts?: { + worker?: RequestWorker; + }, + ): Promise { + const requestWorker = opts?.worker; + + const conditions: SQL[] = [eq(schema.restaurants.id, id)]; + + if (requestWorker) { + if (requestWorker.role === "OWNER") { + conditions.push(eq(schema.restaurants.ownerId, requestWorker.id)); + } else if ( + requestWorker.role !== "SYSTEM_ADMIN" && + requestWorker.role !== "CHIEF_ADMIN" + ) { + if (requestWorker?.workersToRestaurants.length === 0) { + throw new NotFoundException(`Restaurant with id ${id} not found`); + } + + conditions.push( + inArray( + schema.restaurants.id, + requestWorker.workersToRestaurants.map((r) => r.restaurantId), + ), + ); + } + } + + const data = await this.pg.query.restaurants.findFirst({ + where: and(...conditions), + }); + + if (!data) { + throw new NotFoundException(`Restaurant with id ${id} not found`); + } + + return data; + } + + /** + * Create a new restaurant + * @param dto + * @returns + */ + public async create( + dto: CreateRestaurantDto, + opts?: { worker?: RequestWorker }, + ): Promise { + const requestWorker = opts?.worker; + + if (dto.timezone && !this.timezonesService.checkTimezone(dto.timezone)) { + throw new BadRequestException( + "errors.restaurants.provided-timezone-cant-be-set", + { + property: "timezone", + }, + ); + } + + const data = await this.pg + .insert(schema.restaurants) + .values({ + ...dto, + ...(requestWorker?.role === "OWNER" && { + ownerId: requestWorker.id, + }), + }) + .returning({ + id: schema.restaurants.id, + }); + + return await this.findById(data[0].id); + } + + private async validateRestaurantOwnerUnasignment(restaurantId: string) { + const menus = await this.pg.query.dishesMenusToRestaurants.findMany({ + where: (dishesMenusToRestaurants, { eq }) => + eq(dishesMenusToRestaurants.restaurantId, restaurantId), + columns: { + dishesMenuId: true, + }, + limit: 1, + }); + + if (menus.length > 0) { + throw new BadRequestException( + "errors.restaurants.restaurant-was-assigned-to-some-owner-menus", + ); + } + } + + /** + * Update a restaurant + * @param id + * @param dto + * @returns + */ + public async update( + id: string, + payload: UpdateRestaurantDto, + options: { worker: RequestWorker }, + ): Promise { + const { worker } = options; + const { timezone, ownerId, ...rest } = payload; + + const restaurant = await this.pg.query.restaurants.findFirst({ + where: (restaurants, { eq }) => eq(restaurants.id, id), + columns: { + ownerId: true, + }, + }); + + if (!restaurant) { + throw new NotFoundException(); + } + + // If role is owner, only owner can update restaurant + if (worker.role === "OWNER" && restaurant.ownerId !== worker.id) { + throw new BadRequestException( + "errors.restaurants.you-dont-have-rights-to-that-restaurant", + ); + } + + // If role is admin, only admins of that restaurant can update it + if ( + worker.role === "ADMIN" && + !worker.workersToRestaurants.some((r) => r.restaurantId === id) + ) { + throw new BadRequestException( + "errors.restaurants.you-dont-have-rights-to-that-restaurant", + ); + } + + if (timezone && !this.timezonesService.checkTimezone(timezone)) { + throw new BadRequestException( + "errors.restaurants.provided-timezone-cant-be-set", + { + property: "timezone", + }, + ); + } + + // If trying to unasign restaurant from owner + if (restaurant.ownerId && ownerId === null) { + await this.validateRestaurantOwnerUnasignment(id); + } + + // Disable restaurant if it is closed forever + if (rest.isClosedForever) { + rest.isEnabled = false; + } + + await this.pg + .update(schema.restaurants) + .set({ + ...rest, + ...(timezone ? { timezone } : {}), + // Only system admins and chief admins can update restaurant owner + ...(ownerId && + (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") + ? { ownerId } + : {}), + }) + .where(eq(schema.restaurants.id, id)); + + return await this.findById(id); + } + + /** + * Delete a restaurant + * @param id + * @returns + */ + // TODO: implement removement of restaurants + public async delete(id: string): Promise { + throw new BadRequestException(); + await this.pg + .delete(schema.restaurants) + .where(eq(schema.restaurants.id, id)); + } + + public async isExists(id: string): Promise { + return !!(await this.pg.query.restaurants.findFirst({ + where: eq(schema.restaurants.id, id), + })); + } +} diff --git a/src/restaurants/controllers/restaurants.controller.ts b/src/restaurants/controllers/restaurants.controller.ts deleted file mode 100644 index 66fc18a..0000000 --- a/src/restaurants/controllers/restaurants.controller.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Controller } from "@core/decorators/controller.decorator"; -import { - IPagination, - PaginationParams, -} from "@core/decorators/pagination.decorator"; -import { Roles } from "@core/decorators/roles.decorator"; -import { Serializable } from "@core/decorators/serializable.decorator"; -import { Body, Delete, Get, Param, Post, Put } from "@nestjs/common"; -import { - ApiCreatedResponse, - ApiForbiddenResponse, - ApiNoContentResponse, - ApiNotFoundResponse, - ApiOkResponse, - ApiOperation, - ApiUnauthorizedResponse, -} from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; - -import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; -import { RestaurantDto } from "../dto/restaurant.dto"; -import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; -import { RestaurantsPaginatedDto } from "../dto/views/get-restaurants.view"; -import { RestaurantsService } from "../services/restaurants.service"; - -@RequireSessionAuth() -@Controller("restaurants") -@ApiForbiddenResponse({ description: "Forbidden" }) -@ApiUnauthorizedResponse({ description: "Unauthorized" }) -export class RestaurantsController { - constructor(private readonly restaurantsService: RestaurantsService) {} - - @Get() - @ApiOperation({ - summary: "Gets restaurants that created in system", - }) - @Serializable(RestaurantsPaginatedDto) - @ApiOkResponse({ - description: "Restaurants have been successfully fetched", - type: RestaurantsPaginatedDto, - }) - async findAll(@PaginationParams() pagination: IPagination) { - const total = await this.restaurantsService.getTotalCount(); - const data = await this.restaurantsService.findMany({ pagination }); - - return { - data, - meta: { - ...pagination, - total, - }, - }; - } - - @Post() - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") - @Serializable(RestaurantDto) - @ApiOperation({ - summary: "Creates a new restaurant", - }) - @ApiCreatedResponse({ - description: "Restaurant has been successfully created", - type: RestaurantDto, - }) - @ApiForbiddenResponse({ - description: "Action available only for SYSTEM_ADMIN, CHIEF_ADMIN", - }) - async create(@Body() dto: CreateRestaurantDto): Promise { - return await this.restaurantsService.create(dto); - } - - @Get(":id") - @Serializable(RestaurantDto) - @ApiOperation({ - summary: "Gets restaurant by id", - }) - @ApiOkResponse({ - description: "Restaurant has been successfully fetched", - type: RestaurantDto, - }) - @ApiNotFoundResponse({ - description: "Restaurant with this id not found", - }) - async findOne(@Param("id") id: string): Promise { - return await this.restaurantsService.findById(id); - } - - @Put(":id") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") - @Serializable(RestaurantDto) - @ApiOperation({ - summary: "Updates restaurant by id", - }) - @ApiOkResponse({ - description: "Restaurant has been successfully updated", - type: RestaurantDto, - }) - @ApiNotFoundResponse({ - description: "Restaurant with this id not found", - }) - @ApiForbiddenResponse({ - description: "Action available only for SYSTEM_ADMIN, CHIEF_ADMIN", - }) - async update( - @Param("id") id: string, - @Body() dto: UpdateRestaurantDto, - ): Promise { - return await this.restaurantsService.update(id, dto); - } - - @Delete(":id") - @Roles("SYSTEM_ADMIN") - @ApiOperation({ - summary: "Deletes restaurant by id", - }) - @ApiNoContentResponse({ - description: "Restaurant has been successfully deleted", - }) - @ApiNotFoundResponse({ - description: "Restaurant with this id not found", - }) - @ApiForbiddenResponse({ - description: "Action available only for SYSTEM_ADMIN", - }) - async delete(@Param("id") id: string): Promise { - return await this.restaurantsService.delete(id); - } -} diff --git a/src/restaurants/dish-modifiers/dto/create-restaurant-dish-modifier.dto.ts b/src/restaurants/dish-modifiers/dto/create-restaurant-dish-modifier.dto.ts new file mode 100644 index 0000000..8e5d85d --- /dev/null +++ b/src/restaurants/dish-modifiers/dto/create-restaurant-dish-modifier.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from "@nestjs/swagger"; +import { RestaurantDishModifierEntity } from "src/restaurants/dish-modifiers/entities/restaurant-dish-modifier.entity"; + +export class CreateRestaurantDishModifierDto extends OmitType( + RestaurantDishModifierEntity, + ["id", "createdAt", "updatedAt", "removedAt", "isRemoved"] as const, +) {} diff --git a/src/restaurants/dish-modifiers/dto/update-restraurant-dish-modifier.dto.ts b/src/restaurants/dish-modifiers/dto/update-restraurant-dish-modifier.dto.ts new file mode 100644 index 0000000..98dee7b --- /dev/null +++ b/src/restaurants/dish-modifiers/dto/update-restraurant-dish-modifier.dto.ts @@ -0,0 +1,7 @@ +import { OmitType, PartialType } from "@nestjs/swagger"; +import { CreateRestaurantDishModifierDto } from "src/restaurants/dish-modifiers/dto/create-restaurant-dish-modifier.dto"; + +export class UpdateRestaurantDishModifierDto extends OmitType( + PartialType(CreateRestaurantDishModifierDto), + ["restaurantId"] as const, +) {} diff --git a/src/restaurants/dish-modifiers/entities/restaurant-dish-modifier.entity.ts b/src/restaurants/dish-modifiers/entities/restaurant-dish-modifier.entity.ts new file mode 100644 index 0000000..3249f52 --- /dev/null +++ b/src/restaurants/dish-modifiers/entities/restaurant-dish-modifier.entity.ts @@ -0,0 +1,77 @@ +import { + IsBoolean, + IsISO8601, + IsOptional, + IsString, + IsUUID, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IDishModifier } from "@postgress-db/schema/dish-modifiers"; +import { Expose } from "class-transformer"; + +export class RestaurantDishModifierEntity implements IDishModifier { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the dish modifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the dish modifier", + example: "Extra Cheese", + }) + name: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the restaurant", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + restaurantId: string; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is the modifier active", + example: true, + }) + isActive: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is the modifier removed", + example: false, + }) + isRemoved: boolean; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Timestamp of creation", + example: "2024-01-01T00:00:00.000Z", + }) + createdAt: Date; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Timestamp of last update", + example: "2024-01-01T00:00:00.000Z", + }) + updatedAt: Date; + + @IsOptional() + @IsISO8601() + @Expose() + @ApiPropertyOptional({ + description: "Timestamp when modifier was removed", + example: "2024-01-01T00:00:00.000Z", + }) + removedAt: Date | null; +} diff --git a/src/restaurants/dish-modifiers/restaurant-dish-modifiers.controller.ts b/src/restaurants/dish-modifiers/restaurant-dish-modifiers.controller.ts new file mode 100644 index 0000000..7185ec5 --- /dev/null +++ b/src/restaurants/dish-modifiers/restaurant-dish-modifiers.controller.ts @@ -0,0 +1,125 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Body, Delete, Get, Param, Post, Put } from "@nestjs/common"; +import { + ApiCreatedResponse, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + OmitType, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { RestaurantGuard } from "src/restaurants/@/decorators/restaurant-guard.decorator"; + +import { CreateRestaurantDishModifierDto } from "./dto/create-restaurant-dish-modifier.dto"; +import { UpdateRestaurantDishModifierDto } from "./dto/update-restraurant-dish-modifier.dto"; +import { RestaurantDishModifierEntity } from "./entities/restaurant-dish-modifier.entity"; +import { RestaurantDishModifiersService } from "./restaurant-dish-modifiers.service"; + +export class CreateRestaurantDishModifierPayloadDto extends OmitType( + CreateRestaurantDishModifierDto, + ["restaurantId"] as const, +) {} + +type RestaurantDishModifiersRouteParams = { + id: string; + modifierId?: string; +}; + +@Controller("restaurants/:id/dish-modifiers", { + tags: ["restaurants"], +}) +export class RestaurantDishModifiersController { + constructor( + private readonly restaurantDishModifiersService: RestaurantDishModifiersService, + ) {} + + @RestaurantGuard({ + restaurantId: (req) => + (req.params as RestaurantDishModifiersRouteParams).id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(RestaurantDishModifierEntity) + @ApiOperation({ summary: "Gets restaurant dish modifiers" }) + @ApiOkResponse({ + description: "Restaurant dish modifiers have been successfully fetched", + type: [RestaurantDishModifierEntity], + }) + async findAll(@Param("id") id: string) { + return await this.restaurantDishModifiersService.findMany(id); + } + + @RestaurantGuard({ + restaurantId: (req) => + (req.params as RestaurantDishModifiersRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Post() + @Serializable(RestaurantDishModifierEntity) + @ApiOperation({ summary: "Creates restaurant dish modifier" }) + @ApiCreatedResponse({ + description: "Restaurant dish modifier has been successfully created", + type: RestaurantDishModifierEntity, + }) + @ApiForbiddenResponse({ + description: "Forbidden, allowed only for SYSTEM_ADMIN and CHIEF_ADMIN", + }) + async create( + @Param("id") restaurantId: string, + @Body() dto: CreateRestaurantDishModifierPayloadDto, + ) { + return await this.restaurantDishModifiersService.create({ + ...dto, + restaurantId, + }); + } + + @RestaurantGuard({ + restaurantId: (req) => + (req.params as RestaurantDishModifiersRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Put(":modifierId") + @Serializable(RestaurantDishModifierEntity) + @ApiOperation({ summary: "Updates restaurant dish modifier" }) + @ApiOkResponse({ + description: "Restaurant dish modifier has been successfully updated", + type: RestaurantDishModifierEntity, + }) + @ApiForbiddenResponse({ + description: "Forbidden, allowed only for SYSTEM_ADMIN and CHIEF_ADMIN", + }) + async update( + @Param("modifierId") id: string, + @Body() dto: UpdateRestaurantDishModifierDto, + ) { + return await this.restaurantDishModifiersService.update(id, dto); + } + + @RestaurantGuard({ + restaurantId: (req) => + (req.params as RestaurantDishModifiersRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Delete(":modifierId") + @ApiOperation({ summary: "Removes restaurant dish modifier" }) + @ApiOkResponse({ + description: "Restaurant dish modifier has been successfully removed", + }) + @ApiForbiddenResponse({ + description: "Forbidden, allowed only for SYSTEM_ADMIN and CHIEF_ADMIN", + }) + async remove( + @Param("id") id: string, + @Param("modifierId") modifierId: string, + ) { + await this.restaurantDishModifiersService.remove(modifierId, id); + + return; + } +} diff --git a/src/restaurants/dish-modifiers/restaurant-dish-modifiers.service.ts b/src/restaurants/dish-modifiers/restaurant-dish-modifiers.service.ts new file mode 100644 index 0000000..c8caa2d --- /dev/null +++ b/src/restaurants/dish-modifiers/restaurant-dish-modifiers.service.ts @@ -0,0 +1,135 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { and, eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; + +import { RestaurantsService } from "../@/services/restaurants.service"; + +import { CreateRestaurantDishModifierDto } from "./dto/create-restaurant-dish-modifier.dto"; +import { UpdateRestaurantDishModifierDto } from "./dto/update-restraurant-dish-modifier.dto"; +import { RestaurantDishModifierEntity } from "./entities/restaurant-dish-modifier.entity"; + +@Injectable() +export class RestaurantDishModifiersService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly restaurantsService: RestaurantsService, + ) {} + + private async isExists(id: string, restaurantId?: string): Promise { + return !!(await this.pg.query.dishModifiers.findFirst({ + where: !restaurantId + ? eq(schema.dishModifiers.id, id) + : and( + eq(schema.dishModifiers.id, id), + eq(schema.dishModifiers.restaurantId, restaurantId), + ), + })); + } + + /** + * Find many restaurant dish modifiers + * @param restaurantId + * @returns Array of dish modifiers + */ + public async findMany( + restaurantId: string, + ): Promise { + if (!(await this.restaurantsService.isExists(restaurantId))) { + throw new BadRequestException( + "errors.restaurants.with-provided-id-doesnt-exist", + { + property: "restaurantId", + }, + ); + } + + return await this.pg.query.dishModifiers.findMany({ + where: and( + eq(schema.dishModifiers.restaurantId, restaurantId), + eq(schema.dishModifiers.isRemoved, false), + ), + }); + } + + /** + * Create restaurant dish modifier + * @param dto + * @returns Created dish modifier + */ + public async create( + dto: CreateRestaurantDishModifierDto, + ): Promise { + if (!(await this.restaurantsService.isExists(dto.restaurantId))) { + throw new BadRequestException( + "errors.restaurants.with-provided-id-doesnt-exist", + { + property: "restaurantId", + }, + ); + } + + const data = await this.pg + .insert(schema.dishModifiers) + .values(dto) + .returning(); + + return data[0]; + } + + /** + * Update restaurant dish modifier + * @param id + * @param dto + * @returns Updated dish modifier + */ + public async update( + id: string, + dto: UpdateRestaurantDishModifierDto, + ): Promise { + if (!(await this.isExists(id))) { + throw new BadRequestException( + "errors.restaurant-dish-modifiers.with-this-id-doesnt-exist", + ); + } + + const data = await this.pg + .update(schema.dishModifiers) + .set({ ...dto, updatedAt: new Date() }) + .where(eq(schema.dishModifiers.id, id)) + .returning(); + + return data[0]; + } + + /** + * Mark dish modifier as removed + * @param id + * @param restaurantId + * @returns Removed dish modifier id + */ + public async remove( + id: string, + restaurantId?: string, + ): Promise<{ id: string }> { + if (!(await this.isExists(id, restaurantId))) { + throw new BadRequestException( + "errors.restaurant-dish-modifiers.with-this-id-doesnt-exist", + ); + } + + const result = await this.pg + .update(schema.dishModifiers) + .set({ + isRemoved: true, + removedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(schema.dishModifiers.id, id)) + .returning(); + + return { id: result[0].id }; + } +} diff --git a/src/restaurants/dto/create-restaurant.dto.ts b/src/restaurants/dto/create-restaurant.dto.ts deleted file mode 100644 index df6eb71..0000000 --- a/src/restaurants/dto/create-restaurant.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { PickType } from "@nestjs/swagger"; - -import { RestaurantDto } from "./restaurant.dto"; - -export class CreateRestaurantDto extends PickType(RestaurantDto, [ - "name", - "legalEntity", - "address", - "latitude", - "longitude", - "isEnabled", -]) {} diff --git a/src/restaurants/dto/restaurant-with-hours.dto.ts b/src/restaurants/dto/restaurant-with-hours.dto.ts deleted file mode 100644 index 76840f3..0000000 --- a/src/restaurants/dto/restaurant-with-hours.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { Expose, Type } from "class-transformer"; - -import { RestaurantHoursDto } from "./restaurant-hours.dto"; -import { RestaurantDto } from "./restaurant.dto"; - -export class RestaurantWithHoursDto extends RestaurantDto { - @Expose() - @ApiProperty({ - description: "Array of restaurant hours", - type: [RestaurantHoursDto], - }) - @Type(() => RestaurantHoursDto) - hours: RestaurantHoursDto[]; -} diff --git a/src/restaurants/dto/restaurant-hours.dto.ts b/src/restaurants/hours/entities/restaurant-hours.entity.ts similarity index 66% rename from src/restaurants/dto/restaurant-hours.dto.ts rename to src/restaurants/hours/entities/restaurant-hours.entity.ts index b4dd160..0011491 100644 --- a/src/restaurants/dto/restaurant-hours.dto.ts +++ b/src/restaurants/hours/entities/restaurant-hours.entity.ts @@ -1,9 +1,17 @@ +import { IsTimeFormat } from "@core/decorators/is-time-format.decorator"; +import { DayOfWeek, DayOfWeekEnum } from "@core/types/general"; +import { + IsBoolean, + IsEnum, + IsISO8601, + IsString, + IsUUID, +} from "@i18n-class-validator"; import { ApiProperty, OmitType, PartialType, PickType } from "@nestjs/swagger"; -import { IRestaurantHours } from "@postgress-db/schema"; +import { IRestaurantHours } from "@postgress-db/schema/restaurants"; import { Expose } from "class-transformer"; -import { IsBoolean, IsISO8601, IsString, IsUUID } from "class-validator"; -export class RestaurantHoursDto implements IRestaurantHours { +export class RestaurantHoursEntity implements IRestaurantHours { @IsUUID() @Expose() @ApiProperty({ @@ -20,27 +28,32 @@ export class RestaurantHoursDto implements IRestaurantHours { }) restaurantId: string; - @IsString() + @IsEnum(Object.values(DayOfWeekEnum)) @Expose() @ApiProperty({ description: "Day of the week for hours", - example: "Monday", + example: "monday", + enum: Object.values(DayOfWeekEnum), }) - dayOfWeek: string; + dayOfWeek: DayOfWeek; @IsString() + @IsTimeFormat() @Expose() @ApiProperty({ - description: "Opening time for hours", + description: "Opening time for hours (24-hour format)", example: "10:00", + pattern: "^([01]\\d|2[0-3]):([0-5]\\d)$", }) openingTime: string; @IsString() + @IsTimeFormat() @Expose() @ApiProperty({ - description: "Closing time for hours", + description: "Closing time for hours (24-hour format)", example: "22:00", + pattern: "^([01]\\d|2[0-3]):([0-5]\\d)$", }) closingTime: string; @@ -69,7 +82,7 @@ export class RestaurantHoursDto implements IRestaurantHours { updatedAt: Date; } -export class CreateRestaurantHoursDto extends PickType(RestaurantHoursDto, [ +export class CreateRestaurantHoursDto extends PickType(RestaurantHoursEntity, [ "restaurantId", "dayOfWeek", "openingTime", diff --git a/src/restaurants/controllers/restaurant-hours.controller.ts b/src/restaurants/hours/restaurant-hours.controller.ts similarity index 66% rename from src/restaurants/controllers/restaurant-hours.controller.ts rename to src/restaurants/hours/restaurant-hours.controller.ts index a0f55d5..50e186e 100644 --- a/src/restaurants/controllers/restaurant-hours.controller.ts +++ b/src/restaurants/hours/restaurant-hours.controller.ts @@ -1,5 +1,4 @@ import { Controller } from "@core/decorators/controller.decorator"; -import { Roles } from "@core/decorators/roles.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; import { Body, Delete, Get, Param, Post, Put } from "@nestjs/common"; import { @@ -9,21 +8,26 @@ import { ApiOperation, OmitType, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { RestaurantGuard } from "src/restaurants/@/decorators/restaurant-guard.decorator"; import { CreateRestaurantHoursDto, - RestaurantHoursDto, + RestaurantHoursEntity, UpdateRestaurantHoursDto, -} from "../dto/restaurant-hours.dto"; -import { RestaurantHoursService } from "../services/restaurant-hours.service"; +} from "./entities/restaurant-hours.entity"; +import { RestaurantHoursService } from "./restaurant-hours.service"; export class CreateRestaurantHoursPayloadDto extends OmitType( CreateRestaurantHoursDto, ["restaurantId"] as const, ) {} -@RequireSessionAuth() +type RestaurantHoursRouteParams = { + id: string; + hoursId?: string; +}; + @Controller("restaurants/:id/hours", { tags: ["restaurants"], }) @@ -32,19 +36,28 @@ export class RestaurantHoursController { private readonly restaurantHoursService: RestaurantHoursService, ) {} + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantHoursRouteParams).id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) + @EnableAuditLog({ onlyErrors: true }) @Get() - @Serializable(RestaurantHoursDto) + @Serializable(RestaurantHoursEntity) @ApiOperation({ summary: "Gets restaurant hours" }) @ApiOkResponse({ description: "Restaurant hours have been successfully fetched", - type: [RestaurantHoursDto], + type: [RestaurantHoursEntity], }) async findAll(@Param("id") id: string) { return await this.restaurantHoursService.findMany(id); } + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantHoursRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() @Post() - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Creates restaurant hours" }) @ApiCreatedResponse({ description: "Restaurant hours have been successfully created", @@ -62,9 +75,13 @@ export class RestaurantHoursController { }); } + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantHoursRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() @Put(":hoursId") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") - @Serializable(RestaurantHoursDto) + @Serializable(RestaurantHoursEntity) @ApiOperation({ summary: "Updates restaurant hours" }) @ApiOkResponse({ description: "Restaurant hours have been successfully updated", @@ -79,8 +96,12 @@ export class RestaurantHoursController { return await this.restaurantHoursService.update(id, dto); } + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantHoursRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() @Delete(":hoursId") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Deletes restaurant hours" }) @ApiOkResponse({ description: "Restaurant hours have been successfully deleted", diff --git a/src/restaurants/services/restaurant-hours.service.ts b/src/restaurants/hours/restaurant-hours.service.ts similarity index 59% rename from src/restaurants/services/restaurant-hours.service.ts rename to src/restaurants/hours/restaurant-hours.service.ts index 2ef4a82..5de287e 100644 --- a/src/restaurants/services/restaurant-hours.service.ts +++ b/src/restaurants/hours/restaurant-hours.service.ts @@ -1,17 +1,17 @@ import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; import { Inject, Injectable } from "@nestjs/common"; -import * as schema from "@postgress-db/schema"; +import { schema } from "@postgress-db/drizzle.module"; import { and, eq } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; +import { RestaurantsService } from "../@/services/restaurants.service"; + import { CreateRestaurantHoursDto, - RestaurantHoursDto, + RestaurantHoursEntity, UpdateRestaurantHoursDto, -} from "../dto/restaurant-hours.dto"; - -import { RestaurantsService } from "./restaurants.service"; +} from "./entities/restaurant-hours.entity"; @Injectable() export class RestaurantHoursService { @@ -36,15 +36,21 @@ export class RestaurantHoursService { * @param restaurantId * @returns */ - public async findMany(restaurantId: string): Promise { + public async findMany( + restaurantId: string, + ): Promise { if (!(await this.restaurantsService.isExists(restaurantId))) { throw new BadRequestException( - `Restaurant with id ${restaurantId} not found`, + "errors.restaurants.with-provided-id-doesnt-exist", + { + property: "restaurantId", + }, ); } return await this.pg.query.restaurantHours.findMany({ where: eq(schema.restaurantHours.restaurantId, restaurantId), + orderBy: schema.restaurantHours.dayOfWeek, }); } @@ -53,7 +59,7 @@ export class RestaurantHoursService { * @param id * @returns */ - public async findOne(id: string): Promise { + public async findOne(id: string): Promise { return await this.pg.query.restaurantHours.findFirst({ where: eq(schema.restaurantHours.id, id), }); @@ -66,21 +72,33 @@ export class RestaurantHoursService { */ public async create( dto: CreateRestaurantHoursDto, - ): Promise { + ): Promise { if (!(await this.restaurantsService.isExists(dto.restaurantId))) { throw new BadRequestException( - `Restaurant with id ${dto.restaurantId} not found`, + "errors.restaurants.with-provided-id-doesnt-exist", + { + property: "restaurantId", + }, ); } + // Make all previous value with dto.dayOfWeek to disabled + await this.pg + .update(schema.restaurantHours) + .set({ isEnabled: false }) + .where( + and( + eq(schema.restaurantHours.restaurantId, dto.restaurantId), + eq(schema.restaurantHours.dayOfWeek, dto.dayOfWeek), + ), + ); + const data = await this.pg .insert(schema.restaurantHours) .values(dto) - .returning({ - id: schema.restaurantHours.id, - }); + .returning(); - return await this.findOne(data?.[0].id); + return data[0]; } /** @@ -92,17 +110,18 @@ export class RestaurantHoursService { public async update( id: string, dto: UpdateRestaurantHoursDto, - ): Promise { - if (!(await this.restaurantsService.isExists(id))) { - throw new BadRequestException(`Restaurant with id ${id} not found`); + ): Promise { + if (!(await this.isExists(id))) { + throw new BadRequestException(`Hour record with id ${id} not found`); } - await this.pg + const data = await this.pg .update(schema.restaurantHours) .set(dto) - .where(eq(schema.restaurantHours.id, id)); + .where(eq(schema.restaurantHours.id, id)) + .returning(); - return await this.findOne(id); + return data[0]; } /** @@ -115,11 +134,16 @@ export class RestaurantHoursService { restaurantId?: string, ): Promise<{ id: string }> { if (!(await this.isExists(id, restaurantId))) { - throw new BadRequestException(`Restaurant hours with id ${id} not found`); + throw new BadRequestException( + "errors.restaurant-hours.with-this-id-doesnt-exist", + ); } - return await this.pg + const result = await this.pg .delete(schema.restaurantHours) - .where(eq(schema.restaurantHours.id, id)); + .where(eq(schema.restaurantHours.id, id)) + .returning(); + + return { id: result[0].id }; } } diff --git a/src/restaurants/restaurants.module.ts b/src/restaurants/restaurants.module.ts index 442d60b..f7e84bc 100644 --- a/src/restaurants/restaurants.module.ts +++ b/src/restaurants/restaurants.module.ts @@ -1,15 +1,38 @@ import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { RestaurantWorkshiftPaymentCategoriesController } from "src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.controller"; +import { RestaurantWorkshiftPaymentCategoriesService } from "src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service"; +import { TimezonesModule } from "src/timezones/timezones.module"; -import { RestaurantHoursController } from "./controllers/restaurant-hours.controller"; -import { RestaurantsController } from "./controllers/restaurants.controller"; -import { RestaurantHoursService } from "./services/restaurant-hours.service"; -import { RestaurantsService } from "./services/restaurants.service"; +import { RestaurantsController } from "./@/controllers/restaurants.controller"; +import { RestaurantsService } from "./@/services/restaurants.service"; +import { RestaurantDishModifiersController } from "./dish-modifiers/restaurant-dish-modifiers.controller"; +import { RestaurantDishModifiersService } from "./dish-modifiers/restaurant-dish-modifiers.service"; +import { RestaurantHoursController } from "./hours/restaurant-hours.controller"; +import { RestaurantHoursService } from "./hours/restaurant-hours.service"; +import { RestaurantWorkshopsController } from "./workshops/restaurant-workshops.controller"; +import { RestaurantWorkshopsService } from "./workshops/restaurant-workshops.service"; @Module({ - imports: [DrizzleModule], - providers: [RestaurantsService, RestaurantHoursService], - controllers: [RestaurantsController, RestaurantHoursController], - exports: [RestaurantsService, RestaurantHoursService], + imports: [DrizzleModule, TimezonesModule], + providers: [ + RestaurantsService, + RestaurantHoursService, + RestaurantWorkshopsService, + RestaurantDishModifiersService, + RestaurantWorkshiftPaymentCategoriesService, + ], + controllers: [ + RestaurantsController, + RestaurantHoursController, + RestaurantWorkshopsController, + RestaurantDishModifiersController, + RestaurantWorkshiftPaymentCategoriesController, + ], + exports: [ + RestaurantsService, + RestaurantHoursService, + RestaurantWorkshopsService, + ], }) export class RestaurantsModule {} diff --git a/src/restaurants/services/restaurants.service.ts b/src/restaurants/services/restaurants.service.ts deleted file mode 100644 index 3284445..0000000 --- a/src/restaurants/services/restaurants.service.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { IPagination } from "@core/decorators/pagination.decorator"; -import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; -import { Inject, Injectable } from "@nestjs/common"; -import * as schema from "@postgress-db/schema"; -import { count, eq } from "drizzle-orm"; -import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { PG_CONNECTION } from "src/constants"; - -import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; -import { RestaurantDto } from "../dto/restaurant.dto"; -import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; - -@Injectable() -export class RestaurantsService { - constructor( - @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, - ) {} - - /** - * Gets total count of restaurants - * @returns - */ - public async getTotalCount(): Promise { - return await this.pg - .select({ value: count() }) - .from(schema.restaurants) - .then((res) => res[0].value); - } - - /** - * Find many restaurants - * @param options - * @returns - */ - public async findMany(options: { - pagination: IPagination; - }): Promise { - return await this.pg.query.restaurants.findMany({ - limit: options.pagination.size, - offset: options.pagination.offset, - }); - } - - /** - * Find one restaurant by id - * @param id - * @returns - */ - public async findById(id: string): Promise { - const data = await this.pg.query.restaurants.findFirst({ - where: eq(schema.restaurants.id, id), - }); - - if (!data) { - throw new NotFoundException(`Restaurant with id ${id} not found`); - } - - return data; - } - - /** - * Create a new restaurant - * @param dto - * @returns - */ - public async create(dto: CreateRestaurantDto): Promise { - const data = await this.pg - .insert(schema.restaurants) - .values(dto) - .returning({ - id: schema.restaurants.id, - }); - - return await this.findById(data[0].id); - } - - /** - * Update a restaurant - * @param id - * @param dto - * @returns - */ - public async update( - id: string, - dto: UpdateRestaurantDto, - ): Promise { - await this.pg - .update(schema.restaurants) - .set(dto) - .where(eq(schema.restaurants.id, id)); - - return await this.findById(id); - } - - /** - * Delete a restaurant - * @param id - * @returns - */ - - public async delete(id: string): Promise { - await this.pg - .delete(schema.restaurants) - .where(eq(schema.restaurants.id, id)); - } - - public async isExists(id: string): Promise { - return !!(await this.pg.query.restaurants.findFirst({ - where: eq(schema.restaurants.id, id), - })); - } -} diff --git a/src/restaurants/workshift-payment-categories/dto/create-workshift-payment-category.dto.ts b/src/restaurants/workshift-payment-categories/dto/create-workshift-payment-category.dto.ts new file mode 100644 index 0000000..8ace63a --- /dev/null +++ b/src/restaurants/workshift-payment-categories/dto/create-workshift-payment-category.dto.ts @@ -0,0 +1,7 @@ +import { PickType } from "@nestjs/swagger"; +import { WorkshiftPaymentCategoryEntity } from "src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity"; + +export class CreateWorkshiftPaymentCategoryDto extends PickType( + WorkshiftPaymentCategoryEntity, + ["name", "description", "parentId", "type"], +) {} diff --git a/src/restaurants/workshift-payment-categories/dto/update-workshift-payment-category.dto.ts b/src/restaurants/workshift-payment-categories/dto/update-workshift-payment-category.dto.ts new file mode 100644 index 0000000..2634d2d --- /dev/null +++ b/src/restaurants/workshift-payment-categories/dto/update-workshift-payment-category.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, PickType } from "@nestjs/swagger"; +import { WorkshiftPaymentCategoryEntity } from "src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity"; + +export class UpdateWorkshiftPaymentCategoryDto extends PartialType( + PickType(WorkshiftPaymentCategoryEntity, ["name", "description", "isActive"]), +) {} diff --git a/src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity.ts b/src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity.ts new file mode 100644 index 0000000..f749f9a --- /dev/null +++ b/src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity.ts @@ -0,0 +1,125 @@ +import { + IsArray, + IsBoolean, + IsISO8601, + IsOptional, + IsString, + IsUUID, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ZodWorkshiftPaymentType } from "@postgress-db/schema/workshift-enums"; +import { Expose, Type } from "class-transformer"; + +export class WorkshiftPaymentCategoryEntity { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the workshift payment category", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Parent category ID", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + parentId: string | null; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Restaurant ID this category belongs to", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + restaurantId: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Type of the payment category", + enum: ZodWorkshiftPaymentType.Enum, + example: ZodWorkshiftPaymentType.Enum.INCOME, + examples: Object.values(ZodWorkshiftPaymentType.Enum), + }) + type: typeof ZodWorkshiftPaymentType._type; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the payment category", + example: "Tips", + }) + name: string; + + @IsString() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Description of the payment category", + example: "Tips from customers", + }) + description: string | null; + + @Expose() + @ApiProperty({ + description: "Sort index for ordering", + example: 1, + }) + sortIndex: number; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the category is active", + example: true, + }) + isActive: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the category is removed", + example: false, + }) + isRemoved: boolean; + + @IsArray() + @Expose() + @Type(() => WorkshiftPaymentCategoryEntity) + @ApiProperty({ + description: "Children categories", + type: [WorkshiftPaymentCategoryEntity], + }) + childrens: WorkshiftPaymentCategoryEntity[]; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when category was created", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + createdAt: Date; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Date when category was last updated", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + updatedAt: Date; + + @IsISO8601() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Date when category was removed", + example: null, + type: Date, + }) + removedAt: Date | null; +} diff --git a/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.controller.ts b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.controller.ts new file mode 100644 index 0000000..724be3a --- /dev/null +++ b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.controller.ts @@ -0,0 +1,97 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { Body, Delete, Get, Param, Patch, Post } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { CreateWorkshiftPaymentCategoryDto } from "src/restaurants/workshift-payment-categories/dto/create-workshift-payment-category.dto"; +import { UpdateWorkshiftPaymentCategoryDto } from "src/restaurants/workshift-payment-categories/dto/update-workshift-payment-category.dto"; +import { WorkshiftPaymentCategoryEntity } from "src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity"; +import { RestaurantWorkshiftPaymentCategoriesService } from "src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service"; + +@Controller("restaurants/:restaurantId/workshift-payment-categories", { + tags: ["restaurants"], +}) +export class RestaurantWorkshiftPaymentCategoriesController { + constructor( + private readonly restaurantWorkshiftPaymentCategoriesService: RestaurantWorkshiftPaymentCategoriesService, + ) {} + + @EnableAuditLog({ onlyErrors: true }) + @Serializable(WorkshiftPaymentCategoryEntity) + @Get() + @ApiOperation({ summary: "Gets restaurant workshift payment categories" }) + @ApiOkResponse({ + description: + "Restaurant workshift payment categories have been successfully fetched", + type: [WorkshiftPaymentCategoryEntity], + }) + async findAll( + @Param("restaurantId") restaurantId: string, + @Worker() worker: RequestWorker, + ) { + return this.restaurantWorkshiftPaymentCategoriesService.findAll( + restaurantId, + { worker }, + ); + } + + @EnableAuditLog() + @Post() + @ApiOperation({ summary: "Creates restaurant workshift payment category" }) + @ApiOkResponse({ + description: + "Restaurant workshift payment category has been successfully created", + type: WorkshiftPaymentCategoryEntity, + }) + async create( + @Param("restaurantId") restaurantId: string, + @Worker() worker: RequestWorker, + @Body() payload: CreateWorkshiftPaymentCategoryDto, + ) { + return this.restaurantWorkshiftPaymentCategoriesService.create(payload, { + restaurantId, + worker, + }); + } + + @EnableAuditLog() + @Patch(":categoryId") + @ApiOperation({ summary: "Updates restaurant workshift payment category" }) + @ApiOkResponse({ + description: + "Restaurant workshift payment category has been successfully updated", + type: WorkshiftPaymentCategoryEntity, + }) + async update( + @Param("restaurantId") restaurantId: string, + @Param("categoryId") categoryId: string, + @Worker() worker: RequestWorker, + @Body() payload: UpdateWorkshiftPaymentCategoryDto, + ) { + return this.restaurantWorkshiftPaymentCategoriesService.update( + categoryId, + payload, + { worker }, + ); + } + + @EnableAuditLog() + @Delete(":categoryId") + @ApiOperation({ summary: "Removes restaurant workshift payment category" }) + @ApiOkResponse({ + description: + "Restaurant workshift payment category has been successfully removed", + type: WorkshiftPaymentCategoryEntity, + }) + async remove( + @Param("restaurantId") restaurantId: string, + @Param("categoryId") categoryId: string, + @Worker() worker: RequestWorker, + ) { + return this.restaurantWorkshiftPaymentCategoriesService.remove(categoryId, { + worker, + }); + } +} diff --git a/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service.ts b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service.ts new file mode 100644 index 0000000..2f6ce9a --- /dev/null +++ b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service.ts @@ -0,0 +1,144 @@ +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { workshiftPaymentCategories } from "@postgress-db/schema/workshift-payment-category"; +import { eq, or } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { CreateWorkshiftPaymentCategoryDto } from "src/restaurants/workshift-payment-categories/dto/create-workshift-payment-category.dto"; +import { UpdateWorkshiftPaymentCategoryDto } from "src/restaurants/workshift-payment-categories/dto/update-workshift-payment-category.dto"; + +@Injectable() +export class RestaurantWorkshiftPaymentCategoriesService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + // TODO: implement unified service that will do such checks + private _checkRestaurantAccess(restaurantId: string, worker: RequestWorker) { + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + return true; + } else if (worker.role === "OWNER") { + return worker.ownedRestaurants.some((r) => r.id === restaurantId); + } else if (worker.role === "ADMIN" || worker.role === "CASHIER") { + return worker.workersToRestaurants.some( + (r) => r.restaurantId === restaurantId, + ); + } + + return false; + } + + async findAll(restaurantId: string, opts: { worker: RequestWorker }) { + const { worker } = opts; + + if (!this._checkRestaurantAccess(restaurantId, worker)) { + throw new ForbiddenException(); + } + + const categories = await this.pg.query.workshiftPaymentCategories.findMany({ + where: (category, { eq, and, isNull }) => + and( + eq(category.restaurantId, restaurantId), + eq(category.isRemoved, false), + isNull(category.parentId), + ), + with: { + childrens: { + where: (child, { eq }) => eq(child.isRemoved, false), + }, + }, + }); + + return categories; + } + + async create( + payload: CreateWorkshiftPaymentCategoryDto, + opts: { restaurantId: string; worker: RequestWorker }, + ) { + const { restaurantId, worker } = opts; + + if (!this._checkRestaurantAccess(restaurantId, worker)) { + throw new ForbiddenException(); + } + + const [category] = await this.pg + .insert(workshiftPaymentCategories) + .values({ + restaurantId, + ...payload, + }) + .returning(); + + return category; + } + + async update( + categoryId: string, + payload: UpdateWorkshiftPaymentCategoryDto, + opts: { worker: RequestWorker }, + ) { + const { worker } = opts; + + const category = await this.pg.query.workshiftPaymentCategories.findFirst({ + where: (category, { and, eq }) => + and(eq(category.id, categoryId), eq(category.isRemoved, false)), + columns: { + restaurantId: true, + }, + }); + + if (!category) { + throw new NotFoundException(); + } + + await this._checkRestaurantAccess(category.restaurantId, worker); + + const [editedCategory] = await this.pg + .update(workshiftPaymentCategories) + .set(payload) + .where(eq(workshiftPaymentCategories.id, categoryId)) + .returning(); + + return editedCategory; + } + + async remove(categoryId: string, opts: { worker: RequestWorker }) { + const { worker } = opts; + + const category = await this.pg.query.workshiftPaymentCategories.findFirst({ + where: (category, { and, eq }) => + and(eq(category.id, categoryId), eq(category.isRemoved, false)), + columns: { + restaurantId: true, + }, + }); + + if (!category) { + throw new NotFoundException(); + } + + if (!this._checkRestaurantAccess(category.restaurantId, worker)) { + throw new ForbiddenException(); + } + + const [removedCategory] = await this.pg + .update(workshiftPaymentCategories) + .set({ + isRemoved: true, + removedAt: new Date(), + }) + .where( + or( + eq(workshiftPaymentCategories.id, categoryId), + eq(workshiftPaymentCategories.parentId, categoryId), + ), + ) + .returning(); + + return removedCategory; + } +} diff --git a/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts b/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts new file mode 100644 index 0000000..fbb4fba --- /dev/null +++ b/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts @@ -0,0 +1,15 @@ +import { IsArray, IsUUID } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class UpdateRestaurantWorkshopWorkersDto { + @Expose() + @IsArray() + @IsUUID("4", { each: true }) + @ApiProperty({ + description: "Array of worker IDs to assign to the workshop", + example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + type: [String], + }) + workerIds: string[]; +} diff --git a/src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts b/src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts new file mode 100644 index 0000000..bc00993 --- /dev/null +++ b/src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ZodWorkerRole } from "@postgress-db/schema/workers"; +import { Expose } from "class-transformer"; + +export class WorkshopWorkerEntity { + @Expose() + @ApiProperty({ + description: "Worker ID", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workerId: string; + + @Expose() + @ApiProperty({ + description: "Worker name", + example: "John Doe", + }) + name: string; + + @Expose() + @ApiProperty({ + description: "Worker login", + example: "john.doe", + }) + login: string; + + @Expose() + @ApiProperty({ + description: "Worker role", + enum: ZodWorkerRole.Enum, + example: ZodWorkerRole.Enum.ADMIN, + examples: Object.values(ZodWorkerRole.Enum), + }) + role: typeof ZodWorkerRole._type; +} diff --git a/src/restaurants/workshops/entity/restaurant-workshop.entity.ts b/src/restaurants/workshops/entity/restaurant-workshop.entity.ts new file mode 100644 index 0000000..e04c247 --- /dev/null +++ b/src/restaurants/workshops/entity/restaurant-workshop.entity.ts @@ -0,0 +1,71 @@ +import { IsBoolean, IsISO8601, IsString, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, OmitType, PartialType } from "@nestjs/swagger"; +import { IRestaurantWorkshop } from "@postgress-db/schema/restaurant-workshop"; +import { Expose } from "class-transformer"; + +export class RestaurantWorkshopDto implements IRestaurantWorkshop { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the workshop", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the restaurant", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + restaurantId: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the workshop", + example: "Kitchen Workshop", + }) + name: string; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is label printing enabled for this workshop", + example: true, + }) + isLabelPrintingEnabled: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is workshop enabled", + example: true, + }) + isEnabled: boolean; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Timestamp when workshop was created", + example: "2021-01-01T00:00:00.000Z", + }) + createdAt: Date; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Timestamp when workshop was updated", + example: "2021-01-01T00:00:00.000Z", + }) + updatedAt: Date; +} + +export class CreateRestaurantWorkshopDto extends OmitType( + RestaurantWorkshopDto, + ["id", "createdAt", "updatedAt"] as const, +) {} + +export class UpdateRestaurantWorkshopDto extends PartialType( + OmitType(CreateRestaurantWorkshopDto, ["restaurantId"] as const), +) {} diff --git a/src/restaurants/workshops/entity/workshop-worker.entity.ts b/src/restaurants/workshops/entity/workshop-worker.entity.ts new file mode 100644 index 0000000..4e1c97f --- /dev/null +++ b/src/restaurants/workshops/entity/workshop-worker.entity.ts @@ -0,0 +1,34 @@ +import { IsISO8601, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, OmitType } from "@nestjs/swagger"; +import { IWorkshopWorker } from "@postgress-db/schema/restaurant-workshop"; +import { Expose } from "class-transformer"; + +export class WorkshopWorkerDto implements IWorkshopWorker { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the worker", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workerId: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the workshop", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workshopId: string; + + @IsISO8601() + @Expose() + @ApiProperty({ + description: "Timestamp when worker was assigned to workshop", + example: "2021-01-01T00:00:00.000Z", + }) + createdAt: Date; +} + +export class CreateWorkshopWorkerDto extends OmitType(WorkshopWorkerDto, [ + "createdAt", +] as const) {} diff --git a/src/restaurants/workshops/restaurant-workshops.controller.ts b/src/restaurants/workshops/restaurant-workshops.controller.ts new file mode 100644 index 0000000..fe44f1c --- /dev/null +++ b/src/restaurants/workshops/restaurant-workshops.controller.ts @@ -0,0 +1,160 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Body, Delete, Get, Param, Post, Put } from "@nestjs/common"; +import { + ApiCreatedResponse, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + OmitType, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { RestaurantGuard } from "src/restaurants/@/decorators/restaurant-guard.decorator"; + +import { UpdateRestaurantWorkshopWorkersDto } from "./dto/put-restaurant-workshop-workers.dto"; +import { WorkshopWorkerEntity } from "./entity/restaurant-workshop-worker.entity"; +import { + CreateRestaurantWorkshopDto, + RestaurantWorkshopDto, + UpdateRestaurantWorkshopDto, +} from "./entity/restaurant-workshop.entity"; +import { RestaurantWorkshopsService } from "./restaurant-workshops.service"; + +export class CreateRestaurantWorkshopPayloadDto extends OmitType( + CreateRestaurantWorkshopDto, + ["restaurantId"] as const, +) {} + +// Add interface for route parameters +interface RestaurantRouteParams { + id: string; + workshopId?: string; +} + +@Controller("restaurants/:id/workshops", { + tags: ["restaurants"], +}) +export class RestaurantWorkshopsController { + constructor( + private readonly restaurantWorkshopsService: RestaurantWorkshopsService, + ) {} + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(RestaurantWorkshopDto) + @ApiOperation({ summary: "Gets restaurant workshops" }) + @ApiOkResponse({ + description: "Restaurant workshops have been successfully fetched", + type: [RestaurantWorkshopDto], + }) + async findAll(@Param("id") id: string) { + return await this.restaurantWorkshopsService.findMany(id); + } + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Post() + @ApiOperation({ summary: "Creates restaurant workshop" }) + @ApiCreatedResponse({ + description: "Restaurant workshop has been successfully created", + }) + @ApiForbiddenResponse({ + description: "Forbidden, allowed only for SYSTEM_ADMIN and CHIEF_ADMIN", + }) + async create( + @Param("id") restaurantId: string, + @Body() dto: CreateRestaurantWorkshopPayloadDto, + ) { + return await this.restaurantWorkshopsService.create({ + ...dto, + restaurantId, + }); + } + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Put(":workshopId") + @Serializable(RestaurantWorkshopDto) + @ApiOperation({ summary: "Updates restaurant workshop" }) + @ApiOkResponse({ + description: "Restaurant workshop has been successfully updated", + }) + @ApiForbiddenResponse({ + description: "Forbidden, allowed only for SYSTEM_ADMIN and CHIEF_ADMIN", + }) + async update( + @Param("workshopId") id: string, + @Body() dto: UpdateRestaurantWorkshopDto, + ) { + return await this.restaurantWorkshopsService.update(id, dto); + } + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) + @EnableAuditLog({ onlyErrors: true }) + @Get(":workshopId/workers") + @Serializable(WorkshopWorkerEntity) + @ApiOperation({ summary: "Gets workshop workers" }) + @ApiOkResponse({ + description: "Workshop workers have been successfully fetched", + type: [WorkshopWorkerEntity], + }) + async getWorkers(@Param("workshopId") id: string) { + return await this.restaurantWorkshopsService.getWorkers(id); + } + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Put(":workshopId/workers") + @ApiOperation({ summary: "Updates workshop workers" }) + @ApiOkResponse({ + description: "Workshop workers have been successfully updated", + }) + @ApiForbiddenResponse({ + description: "Forbidden, allowed only for SYSTEM_ADMIN and CHIEF_ADMIN", + }) + async updateWorkers( + @Param("workshopId") id: string, + @Body() dto: UpdateRestaurantWorkshopWorkersDto, + ) { + await this.restaurantWorkshopsService.updateWorkers(id, dto.workerIds); + return; + } + + @RestaurantGuard({ + restaurantId: (req) => (req.params as RestaurantRouteParams).id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() + @Delete(":workshopId") + @ApiOperation({ summary: "Deletes restaurant workshop" }) + @ApiOkResponse({ + description: "Restaurant workshop has been successfully deleted", + }) + @ApiForbiddenResponse({ + description: "Forbidden, allowed only for SYSTEM_ADMIN and CHIEF_ADMIN", + }) + async delete( + @Param("id") id: string, + @Param("workshopId") workshopId: string, + ) { + await this.restaurantWorkshopsService.delete(workshopId, id); + + return; + } +} diff --git a/src/restaurants/workshops/restaurant-workshops.service.ts b/src/restaurants/workshops/restaurant-workshops.service.ts new file mode 100644 index 0000000..7703b72 --- /dev/null +++ b/src/restaurants/workshops/restaurant-workshops.service.ts @@ -0,0 +1,215 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { + restaurantWorkshops, + workshopWorkers, +} from "@postgress-db/schema/restaurant-workshop"; +import { workers } from "@postgress-db/schema/workers"; +import { and, eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; + +import { RestaurantsService } from "../@/services/restaurants.service"; + +import { WorkshopWorkerEntity } from "./entity/restaurant-workshop-worker.entity"; +import { + CreateRestaurantWorkshopDto, + RestaurantWorkshopDto, + UpdateRestaurantWorkshopDto, +} from "./entity/restaurant-workshop.entity"; + +@Injectable() +export class RestaurantWorkshopsService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly restaurantsService: RestaurantsService, + ) {} + + public async isExists(id: string, restaurantId?: string): Promise { + return !!(await this.pg.query.restaurantWorkshops.findFirst({ + where: !restaurantId + ? eq(restaurantWorkshops.id, id) + : and( + eq(restaurantWorkshops.id, id), + eq(restaurantWorkshops.restaurantId, restaurantId), + ), + })); + } + + /** + * Find many restaurant workshops + * @param restaurantId + * @returns + */ + public async findMany( + restaurantId: string, + ): Promise { + if (!(await this.restaurantsService.isExists(restaurantId))) { + throw new BadRequestException( + "errors.restaurants.with-provided-id-doesnt-exist", + { + property: "restaurantId", + }, + ); + } + + return await this.pg.query.restaurantWorkshops.findMany({ + where: eq(restaurantWorkshops.restaurantId, restaurantId), + }); + } + + /** + * Find one restaurant workshop + * @param id + * @returns + */ + public async findOne(id: string): Promise { + return await this.pg.query.restaurantWorkshops.findFirst({ + where: eq(restaurantWorkshops.id, id), + }); + } + + /** + * Create restaurant workshop + * @param dto + * @returns + */ + public async create( + dto: CreateRestaurantWorkshopDto, + ): Promise { + if (!(await this.restaurantsService.isExists(dto.restaurantId))) { + throw new BadRequestException( + "errors.restaurants.with-provided-id-doesnt-exist", + { + property: "restaurantId", + }, + ); + } + + const data = await this.pg + .insert(restaurantWorkshops) + .values(dto) + .returning(); + + return data[0]; + } + + /** + * Update restaurant workshop + * @param id + * @param dto + * @returns + */ + public async update( + id: string, + dto: UpdateRestaurantWorkshopDto, + ): Promise { + if (!(await this.isExists(id))) { + throw new BadRequestException( + "errors.restaurant-workshops.with-this-id-doesnt-exist", + { + property: "id", + }, + ); + } + + const data = await this.pg + .update(restaurantWorkshops) + .set(dto) + .where(eq(restaurantWorkshops.id, id)) + .returning(); + + return data[0]; + } + + /** + * Delete restaurant workshop + * @param id + * @returns + */ + public async delete( + id: string, + restaurantId?: string, + ): Promise<{ id: string }> { + if (!(await this.isExists(id, restaurantId))) { + throw new BadRequestException( + "errors.restaurant-workshops.with-this-id-doesnt-exist", + { + property: "id", + }, + ); + } + + const result = await this.pg + .delete(restaurantWorkshops) + .where(eq(restaurantWorkshops.id, id)) + .returning(); + + return { id: result[0].id }; + } + + /** + * Get workshop workers + * @param workshopId + * @returns + */ + public async getWorkers(workshopId: string): Promise { + if (!(await this.isExists(workshopId))) { + throw new BadRequestException( + "errors.restaurant-workshops.with-this-id-doesnt-exist", + { + property: "workshopId", + }, + ); + } + + const result = await this.pg + .select({ + workerId: workers.id, + name: workers.name, + login: workers.login, + role: workers.role, + }) + .from(workshopWorkers) + .innerJoin(workers, eq(workshopWorkers.workerId, workers.id)) + .where(eq(workshopWorkers.workshopId, workshopId)); + + return result; + } + + /** + * Update workshop workers + * @param workshopId + * @param workerIds + * @returns + */ + public async updateWorkers( + workshopId: string, + workerIds: string[], + ): Promise { + if (!(await this.isExists(workshopId))) { + throw new BadRequestException( + "errors.restaurant-workshops.with-this-id-doesnt-exist", + { + property: "workshopId", + }, + ); + } + + // Delete all existing assignments for this workshop + await this.pg + .delete(workshopWorkers) + .where(eq(workshopWorkers.workshopId, workshopId)); + + // If there are workers to assign, insert them + if (workerIds.length > 0) { + await this.pg.insert(workshopWorkers).values( + workerIds.map((workerId) => ({ + workshopId, + workerId, + })), + ); + } + } +} diff --git a/src/sessions/dto/session-payload.ts b/src/sessions/dto/session-payload.ts deleted file mode 100644 index f70b21c..0000000 --- a/src/sessions/dto/session-payload.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { sessions } from "@postgress-db/schema"; -import { Expose } from "class-transformer"; -import { IsUUID } from "class-validator"; -import { createInsertSchema } from "drizzle-zod"; -import { createZodDto } from "nestjs-zod"; - -const insertSessionSchema = createInsertSchema(sessions).pick({ - workerId: true, - httpAgent: true, - refreshToken: true, - ipAddress: true, -}); - -export class SessionPayloadDto extends createZodDto(insertSessionSchema) { - @IsUUID() - @Expose() - workerId!: string; -} diff --git a/src/sessions/sessions.service.ts b/src/sessions/sessions.service.ts deleted file mode 100644 index 47a952c..0000000 --- a/src/sessions/sessions.service.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { UnauthorizedException } from "@core/errors/exceptions/unauthorized.exception"; -import { handleError } from "@core/errors/handleError"; -import { Inject, Injectable } from "@nestjs/common"; -import * as schema from "@postgress-db/schema"; -import { eq } from "drizzle-orm"; -import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { PG_CONNECTION } from "src/constants"; -import { v4 as uuidv4 } from "uuid"; - -import { SessionPayloadDto } from "./dto/session-payload"; - -@Injectable() -export class SessionsService { - constructor( - @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, - ) {} - - private generateToken() { - return `v1-${uuidv4()}-${uuidv4()}-${uuidv4()}-${uuidv4()}`; - } - - public async findByToken(token: string): Promise { - try { - const result = await this.pg.query.sessions.findFirst({ - where: eq(schema.sessions.token, token), - }); - - return result; - } catch (err) { - handleError(err); - } - } - - public async create( - dto: SessionPayloadDto, - ): Promise> { - try { - const { workerId, httpAgent, ipAddress } = dto; - const token = this.generateToken(); - - return await this.pg - .insert(schema.sessions) - .values({ - workerId, - httpAgent, - token, - ipAddress, - refreshedAt: new Date(), - }) - .returning({ - id: schema.sessions.id, - token: schema.sessions.token, - }) - .then((result) => { - return result?.[0]; - }); - } catch (err) { - handleError(err); - } - } - - public async refresh(token: string): Promise { - try { - if (!(await this.isSessionValid(token))) { - throw new UnauthorizedException("Session is expired"); - } - - const newToken = this.generateToken(); - - await this.pg - .update(schema.sessions) - .set({ - token: newToken, - refreshedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.sessions.token, token)); - - return newToken; - } catch (err) { - handleError(err); - } - } - - public async isSessionValid(token: string): Promise { - try { - const session = await this.findByToken(token); - - if (!session) return false; - - // If session is older than 7 days, it's expired - const isExpired = - session.refreshedAt.getTime() + 1000 * 60 * 60 * 24 * 7 < Date.now(); - - return !isExpired; - } catch (err) { - handleError(err); - } - } - - public async destroy(token: string): Promise { - try { - await this.pg - .delete(schema.sessions) - .where(eq(schema.sessions.token, token)); - - return true; - } catch (err) { - handleError(err); - } - } -} diff --git a/src/timezones/entities/timezones-list.entity.ts b/src/timezones/entities/timezones-list.entity.ts new file mode 100644 index 0000000..40b3b32 --- /dev/null +++ b/src/timezones/entities/timezones-list.entity.ts @@ -0,0 +1,17 @@ +import { IsArray, IsString } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class TimezonesListEntity { + @Expose() + @ApiProperty({ + description: "List of timezone names", + examples: ["Europe/Tallinn"], + type: [String], + }) + @IsArray() + @IsString({ + each: true, + }) + timezones: string[]; +} diff --git a/src/timezones/timezones.controller.ts b/src/timezones/timezones.controller.ts new file mode 100644 index 0000000..bb96f2a --- /dev/null +++ b/src/timezones/timezones.controller.ts @@ -0,0 +1,35 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Get } from "@nestjs/common"; +import { + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { TimezonesListEntity } from "src/timezones/entities/timezones-list.entity"; +import { TimezonesService } from "src/timezones/timezones.service"; + +@Controller("timezones") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class TimezonesController { + constructor(private readonly timezonesService: TimezonesService) {} + + @Get() + @ApiOperation({ + summary: "Get list of timezones", + }) + @Serializable(TimezonesListEntity) + @ApiOkResponse({ + description: "Object with array of available timezones", + type: TimezonesListEntity, + }) + getList(): TimezonesListEntity { + const timezones = this.timezonesService.getAllTimezones(); + + return { + timezones, + }; + } +} diff --git a/src/timezones/timezones.module.ts b/src/timezones/timezones.module.ts new file mode 100644 index 0000000..fe5e610 --- /dev/null +++ b/src/timezones/timezones.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TimezonesController } from "src/timezones/timezones.controller"; +import { TimezonesService } from "src/timezones/timezones.service"; + +@Module({ + imports: [], + controllers: [TimezonesController], + providers: [TimezonesService], + exports: [TimezonesService], +}) +export class TimezonesModule {} diff --git a/src/timezones/timezones.service.ts b/src/timezones/timezones.service.ts new file mode 100644 index 0000000..82bb44f --- /dev/null +++ b/src/timezones/timezones.service.ts @@ -0,0 +1,42 @@ +import { DayOfWeekEnum } from "@core/types/general"; +import { Injectable } from "@nestjs/common"; +import { getTimeZones } from "@vvo/tzdb"; +import { format } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; + +@Injectable() +export class TimezonesService { + public getAllTimezones(): string[] { + const timezones = getTimeZones({ + includeUtc: true, + }).filter((tz) => tz.continentCode === "EU" || tz.countryCode === "RU"); + + return timezones.map((tz) => tz.name); + } + + public checkTimezone(timezone: string): boolean { + const timezones = this.getAllTimezones(); + + return timezones.includes(timezone); + } + + public getCurrentDayOfWeek(timezone: string): DayOfWeekEnum { + // Get current date in the specified timezone + const date = toZonedTime(new Date(), timezone); + + // Get day of week in lowercase (monday, tuesday, etc) + const dayOfWeek = format(date, "EEEE").toLowerCase(); + + return dayOfWeek as DayOfWeekEnum; + } + + public getLocalDate(timezone: string): Date { + return toZonedTime(new Date(), timezone); + } + + public getCurrentTime(timezone: string): string { + const date = toZonedTime(new Date(), timezone); + + return format(date, "HH:mm:ss"); + } +} diff --git a/src/workers/dto/req/put-worker.dto.ts b/src/workers/dto/req/put-worker.dto.ts index 2ef8b76..9fb8f28 100644 --- a/src/workers/dto/req/put-worker.dto.ts +++ b/src/workers/dto/req/put-worker.dto.ts @@ -1,3 +1,4 @@ +import { IsDate, IsOptional, IsStrongPassword } from "@i18n-class-validator"; import { ApiProperty, IntersectionType, @@ -5,13 +6,12 @@ import { PickType, } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsStrongPassword } from "class-validator"; import { WorkerEntity } from "src/workers/entities/worker.entity"; export class CreateWorkerDto extends IntersectionType( PickType(WorkerEntity, ["name", "login", "role"]), PartialType( - PickType(WorkerEntity, ["isBlocked", "hiredAt", "firedAt", "restaurantId"]), + PickType(WorkerEntity, ["isBlocked", "hiredAt", "firedAt", "restaurants"]), ), ) { @IsStrongPassword({ @@ -26,6 +26,14 @@ export class CreateWorkerDto extends IntersectionType( description: "Password of the worker (if provided changes is)", }) password: string; + + @IsOptional() + @IsDate() + updatedAt: Date; + + @IsOptional() + @IsDate() + onlineAt: Date; } export class UpdateWorkerDto extends PartialType(CreateWorkerDto) {} diff --git a/src/workers/entities/worker.entity.ts b/src/workers/entities/worker.entity.ts index 9bbabe9..1d6eb60 100644 --- a/src/workers/entities/worker.entity.ts +++ b/src/workers/entities/worker.entity.ts @@ -1,7 +1,5 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IWorker, ZodWorkerRole } from "@postgress-db/schema"; -import { Exclude, Expose } from "class-transformer"; import { + IsArray, IsBoolean, IsEnum, IsISO8601, @@ -9,7 +7,34 @@ import { IsString, IsUUID, MinLength, -} from "class-validator"; +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IWorker, + IWorkersToRestaurants, + ZodWorkerRole, +} from "@postgress-db/schema/workers"; +import { Exclude, Expose, Type } from "class-transformer"; + +export class WorkerRestaurantEntity + implements Pick +{ + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the restaurant", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + restaurantId: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the restaurant", + example: "Restaurant Name", + }) + restaurantName: string; +} export class WorkerEntity implements IWorker { @IsUUID() @@ -20,30 +45,29 @@ export class WorkerEntity implements IWorker { }) id: string; - @IsOptional() @IsString() @Expose() @ApiProperty({ description: "Name of the worker", - example: "Dana Keller", + example: "V Keller", }) name: string; - @IsOptional() - @IsUUID() + @IsArray() @Expose() + @Type(() => WorkerRestaurantEntity) @ApiProperty({ - description: "Unique identifier of the restaurant", - example: null, + description: "Restaurants where worker is employed", + type: [WorkerRestaurantEntity], }) - restaurantId: string | null; + restaurants: WorkerRestaurantEntity[]; @IsString() @MinLength(4) @Expose() @ApiProperty({ description: "Login of the worker", - example: "dana.keller", + example: "vi.keller", }) login: string; @@ -55,6 +79,7 @@ export class WorkerEntity implements IWorker { @Expose() @ApiProperty({ description: "Role of the worker", + enum: ZodWorkerRole.Enum, example: ZodWorkerRole.Enum.SYSTEM_ADMIN, examples: Object.values(ZodWorkerRole.Enum), }) @@ -76,7 +101,8 @@ export class WorkerEntity implements IWorker { example: new Date("2021-08-01T00:00:00.000Z"), type: Date, }) - hiredAt: Date; + @ApiPropertyOptional() + hiredAt: Date | null; @IsOptional() @IsISO8601() @@ -86,7 +112,8 @@ export class WorkerEntity implements IWorker { example: null, type: Date, }) - firedAt: Date; + @ApiPropertyOptional() + firedAt: Date | null; @IsOptional() @IsISO8601() @@ -96,7 +123,8 @@ export class WorkerEntity implements IWorker { example: new Date(), type: Date, }) - onlineAt: Date; + @ApiPropertyOptional() + onlineAt: Date | null; @IsISO8601() @Expose() diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index 52830b3..ff3daaa 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -1,17 +1,18 @@ import { Controller } from "@core/decorators/controller.decorator"; +import { FilterParams, IFilters } from "@core/decorators/filter.decorator"; import { IPagination, PaginationParams, } from "@core/decorators/pagination.decorator"; -import { Roles } from "@core/decorators/roles.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; import { ISorting, SortingParams } from "@core/decorators/sorting.decorator"; import { Worker } from "@core/decorators/worker.decorator"; import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; -import { ConflictException } from "@core/errors/exceptions/conflict.exception"; import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; -import { Body, Get, Param, Post, Put } from "@nestjs/common"; +import { RequestWorker } from "@core/interfaces/request"; +import { StringValuePipe } from "@core/pipes/string.pipe"; +import { Body, Get, Param, Post, Put, Query } from "@nestjs/common"; import { ApiBadRequestResponse, ApiConflictResponse, @@ -20,25 +21,37 @@ import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiQuery, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { IWorker, workerRoleRank } from "@postgress-db/schema"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; +import { + IWorker, + WorkerRole, + workerRoleRank, +} from "@postgress-db/schema/workers"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; import { CreateWorkerDto, UpdateWorkerDto } from "./dto/req/put-worker.dto"; import { WorkersPaginatedDto } from "./dto/res/workers-paginated.dto"; import { WorkerEntity } from "./entities/worker.entity"; import { WorkersService } from "./workers.service"; -@RequireSessionAuth() @Controller("workers") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) export class WorkersController { constructor(private readonly workersService: WorkersService) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @ApiOperation({ summary: "Gets workers that created in system" }) + @ApiQuery({ + name: "restaurantIds", + type: String, + required: false, + description: "Comma separated list of restaurant IDs to filter workers by", + example: "['1', '2', '3']", + }) @Serializable(WorkersPaginatedDto) @ApiOkResponse({ description: "Workers have been successfully fetched", @@ -58,11 +71,24 @@ export class WorkersController { }) sorting: ISorting, @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, + @Query("restaurantIds", new StringValuePipe()) + restaurantIds?: string, ): Promise { - const total = await this.workersService.getTotalCount(); + const parsedRestaurantIds = !!restaurantIds + ? restaurantIds?.split(",") + : undefined; + + const total = await this.workersService.getTotalCount( + filters, + parsedRestaurantIds, + ); + const data = await this.workersService.findMany({ pagination, sorting, + filters, + restaurantIds: parsedRestaurantIds, }); return { @@ -75,8 +101,8 @@ export class WorkersController { } // TODO: add validation of ADMIN restaurant id + @EnableAuditLog() @Post() - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN", "ADMIN") @Serializable(WorkerEntity) @ApiOperation({ summary: "Creates a new worker" }) @ApiCreatedResponse({ description: "Worker has been successfully created" }) @@ -85,36 +111,31 @@ export class WorkersController { description: "Available only for SYSTEM_ADMIN, CHIEF_ADMIN, ADMIN (restaurant scope)", }) - async create(@Body() data: CreateWorkerDto, @Worker() worker: IWorker) { + async create(@Body() data: CreateWorkerDto, @Worker() worker: RequestWorker) { const { role } = data; const roleRank = workerRoleRank[role]; const requesterRoleRank = workerRoleRank[worker.role]; if (role === "SYSTEM_ADMIN") { - throw new ForbiddenException({ - title: "System Admin Role", - description: "You can't create system admin", + throw new ForbiddenException("errors.workers.cant-create-system-admin", { + property: "role", }); } if (requesterRoleRank <= roleRank) { - throw new ForbiddenException({ - title: "Not enough rights", - description: "You can't create worker with this role", - }); - } - - if (await this.workersService.findOneByLogin(data.login)) { - throw new ConflictException({ - title: "Login conflict", - description: "Worker with this login already exists", - }); + throw new ForbiddenException( + "errors.workers.not-enough-rights-to-create-worker-with-this-role", + { + property: "role", + }, + ); } return await this.workersService.create(data); } + @EnableAuditLog({ onlyErrors: true }) @Get(":id") @Serializable(WorkerEntity) @ApiOperation({ summary: "Gets a worker by id" }) @@ -130,21 +151,23 @@ export class WorkersController { }) async findOne(@Param("id") id?: string): Promise { if (!id) { - throw new BadRequestException("Id must be a string and provided"); + throw new BadRequestException( + "errors.common.id-must-be-a-string-and-provided", + ); } const worker = await this.workersService.findById(id); if (!worker) { - throw new NotFoundException("Worker with this id doesn't exist"); + throw new NotFoundException("errors.workers.with-this-id-doesnt-exist"); } return worker; } // TODO: add validation of ADMIN restaurant id + @EnableAuditLog() @Put(":id") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN", "ADMIN") @Serializable(WorkerEntity) @ApiOperation({ summary: "Updates a worker by id" }) @ApiOkResponse({ @@ -163,33 +186,45 @@ export class WorkersController { @Worker() worker: IWorker, ): Promise { if (!id) { - throw new BadRequestException("Id must be a number and provided"); + throw new BadRequestException( + "errors.common.id-must-be-a-string-and-provided", + ); } const { role } = data; - const roleRank = workerRoleRank?.[role]; + const roleRank = workerRoleRank?.[role as WorkerRole]; const requesterRoleRank = workerRoleRank[worker.role]; if (role) { if (role === "SYSTEM_ADMIN") { - throw new ForbiddenException({ - title: "System Admin Role", - description: "You can't create system admin", - }); + throw new ForbiddenException( + "errors.workers.cant-create-system-admin", + { + property: "role", + }, + ); } if (requesterRoleRank <= roleRank) { - throw new ForbiddenException({ - title: "Not enough rights", - description: "You can't create worker with this role", - }); + throw new ForbiddenException( + "errors.workers.not-enough-rights-to-create-worker-with-this-role", + { + property: "role", + }, + ); } } - return await this.workersService.update(id, { + const updatedWorker = await this.workersService.update(id, { ...data, updatedAt: new Date(), }); + + if (!updatedWorker) { + throw new NotFoundException("errors.workers.with-this-id-doesnt-exist"); + } + + return updatedWorker; } } diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index ddef141..01224fe 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -1,62 +1,155 @@ +import { IFilters } from "@core/decorators/filter.decorator"; import { IPagination } from "@core/decorators/pagination.decorator"; import { ISorting } from "@core/decorators/sorting.decorator"; +import env from "@core/env"; import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; import { ConflictException } from "@core/errors/exceptions/conflict.exception"; -import { Inject, Injectable } from "@nestjs/common"; -import * as schema from "@postgress-db/schema"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { Inject, Injectable, OnApplicationBootstrap } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { IWorker } from "@postgress-db/schema/workers"; import * as argon2 from "argon2"; -import { asc, count, desc, eq, sql } from "drizzle-orm"; +import { count, eq, inArray, or, sql, SQL } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { DrizzleUtils } from "src/@base/drizzle/drizzle-utils"; import { PG_CONNECTION } from "src/constants"; import { CreateWorkerDto, UpdateWorkerDto } from "./dto/req/put-worker.dto"; import { WorkerEntity } from "./entities/worker.entity"; @Injectable() -export class WorkersService { +export class WorkersService implements OnApplicationBootstrap { constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} - private checkRestaurantRoleAssignment(role?: schema.IWorker["role"]) { + async onApplicationBootstrap() { + await this.createInitialAdminIfNeeded(); + } + + private checkRestaurantRoleAssignment(role?: IWorker["role"]) { if (role === "SYSTEM_ADMIN" || role === "CHIEF_ADMIN") { - throw new BadRequestException("You can't assign restaurant to this role"); + throw new BadRequestException( + "errors.workers.role.cant-assign-restaurant-to-this-role", + { + property: "role", + }, + ); } return true; } - public async getTotalCount(): Promise { - return await this.pg - .select({ value: count() }) - .from(schema.workers) - .then((res) => res[0].value); + public async getTotalCount( + filters?: IFilters, + restaurantIds?: string[], + ): Promise { + const query = this.pg.select({ value: count() }).from(schema.workers); + + if (filters ?? restaurantIds) { + const conditions: SQL[] = []; + + if (filters) { + const condition = DrizzleUtils.buildFilterConditions( + schema.workers, + filters, + ); + + if (condition) { + conditions.push(condition); + } + } + + if (restaurantIds?.length) { + const subquery = this.pg + .select({ workerId: schema.workersToRestaurants.workerId }) + .from(schema.workersToRestaurants) + .where( + inArray(schema.workersToRestaurants.restaurantId, restaurantIds), + ); + + conditions.push(inArray(schema.workers.id, subquery)); + } + + query.where(conditions.length > 1 ? or(...conditions) : conditions[0]); + } + + return await query.then((res) => res[0].value); } public async findMany(options: { - pagination?: IPagination; - sorting?: ISorting; + pagination: IPagination; + sorting: ISorting; + filters?: IFilters; + restaurantIds?: string[]; }): Promise { - const { pagination, sorting } = options; + const { pagination, sorting, filters, restaurantIds } = options; + + const conditions: SQL[] = []; + + if (filters) { + const condition = DrizzleUtils.buildFilterConditions( + schema.workers, + filters, + ); + + if (condition) { + conditions.push(condition); + } + } - return await this.pg.query.workers.findMany({ - // Sorting + if (restaurantIds?.length) { + const subquery = this.pg + .select({ workerId: schema.workersToRestaurants.workerId }) + .from(schema.workersToRestaurants) + .where( + inArray(schema.workersToRestaurants.restaurantId, restaurantIds), + ); + + conditions.push(inArray(schema.workers.id, subquery)); + } + + const workers = await this.pg.query.workers.findMany({ + ...(conditions.length > 0 + ? { + where: () => + conditions.length > 1 ? or(...conditions) : conditions[0], + } + : {}), + with: { + workersToRestaurants: { + with: { + restaurant: { + columns: { + name: true, + }, + }, + }, + columns: { + restaurantId: true, + }, + }, + }, ...(sorting ? { - orderBy: + orderBy: (workers, { asc, desc }) => [ sorting.sortOrder === "asc" ? asc(sql.identifier(sorting.sortBy)) : desc(sql.identifier(sorting.sortBy)), + ], } : {}), - // Pagination - ...(pagination - ? { - limit: pagination.size, - offset: pagination.offset, - } - : {}), + limit: pagination.size, + offset: pagination.offset, }); + + return workers.map((w) => ({ + ...w, + restaurants: w.workersToRestaurants.map((r) => ({ + restaurantId: r.restaurantId, + restaurantName: r.restaurant.name, + })), + })); } /** @@ -64,10 +157,34 @@ export class WorkersService { * @param id number id of worker * @returns */ - public async findById(id: string): Promise { - return await this.pg.query.workers.findFirst({ - where: eq(schema.workers.id, id), + public async findById(id: string): Promise { + const worker = await this.pg.query.workers.findFirst({ + where: (workers, { eq }) => eq(workers.id, id), + with: { + workersToRestaurants: { + with: { + restaurant: { + columns: { + name: true, + }, + }, + }, + columns: { + restaurantId: true, + }, + }, + }, }); + + if (!worker) return undefined; + + return { + ...worker, + restaurants: worker.workersToRestaurants.map((r) => ({ + restaurantId: r.restaurantId, + restaurantName: r.restaurant.name, + })), + }; } /** @@ -75,10 +192,19 @@ export class WorkersService { * @param value string login * @returns */ - public async findOneByLogin(value: string): Promise { - return await this.pg.query.workers.findFirst({ - where: eq(schema.workers.login, value), + public async findOneByLogin( + value: string, + ): Promise { + const worker = await this.pg.query.workers.findFirst({ + where: (workers, { eq }) => eq(workers.login, value), + columns: { + id: true, + }, }); + + if (!worker?.id) return undefined; + + return await this.findById(worker?.id); } /** @@ -86,20 +212,41 @@ export class WorkersService { * @param dto * @returns */ - public async create(dto: CreateWorkerDto): Promise { - const { password, role, restaurantId, ...rest } = dto; + public async create(dto: CreateWorkerDto): Promise { + const { password, role, restaurants, ...rest } = dto; - if (restaurantId) { + if (restaurants?.length) { this.checkRestaurantRoleAssignment(role); } - const worker = await this.pg.insert(schema.workers).values({ - ...rest, - restaurantId, - role, - passwordHash: await argon2.hash(password), + const workers = await this.pg.transaction(async (tx) => { + const [worker] = await tx + .insert(schema.workers) + .values({ + ...rest, + role, + passwordHash: await argon2.hash(password), + }) + .returning(); + + if (restaurants?.length) { + await tx.insert(schema.workersToRestaurants).values( + restaurants.map((r) => ({ + workerId: worker.id, + restaurantId: r.restaurantId, + })), + ); + } + + return [worker]; }); + const worker = workers[0]; + + if (!worker || !worker.login) { + throw new ServerErrorException("errors.workers.failed-to-create-worker"); + } + return await this.findOneByLogin(worker.login); } @@ -109,48 +256,88 @@ export class WorkersService { * @param dto * @returns */ - public async update(id: string, dto: UpdateWorkerDto): Promise { - const { password, role, login, restaurantId, ...payload } = dto; + public async update( + id: string, + dto: UpdateWorkerDto, + ): Promise { + const { password, role, login, restaurants, ...payload } = dto; - const exist = await this.findOneByLogin(login); + if (login) { + const exist = await this.findOneByLogin(login); - if ( - exist && - exist.id !== id && - exist.login.toLowerCase() === login.toLowerCase() - ) { - throw new ConflictException("Worker with this login already exists"); + if ( + exist && + exist.id !== id && + exist.login.toLowerCase() === login.toLowerCase() + ) { + throw new ConflictException( + "errors.workers.worker-with-this-login-already-exists", + { + property: "login", + }, + ); + } } - if (restaurantId) { + if (restaurants?.length) { this.checkRestaurantRoleAssignment(role); } if (Object.keys(dto).length === 0) { throw new BadRequestException( - "You should provide at least one field to update", + "errors.common.atleast-one-field-should-be-provided", ); } - await this.pg - .update(schema.workers) - .set({ - ...payload, - login, - role, - ...(role === "SYSTEM_ADMIN" || role === "CHIEF_ADMIN" - ? { restaurantId: null } - : { restaurantId }), - ...(password - ? { - passwordHash: await argon2.hash(password), - } - : {}), - }) - .where(eq(schema.workers.id, id)); + await this.pg.transaction(async (tx) => { + await tx + .update(schema.workers) + .set({ + ...payload, + login, + role, + ...(password + ? { + passwordHash: await argon2.hash(password), + } + : {}), + }) + .where(eq(schema.workers.id, id)); + + if (restaurants) { + await tx + .delete(schema.workersToRestaurants) + .where(eq(schema.workersToRestaurants.workerId, id)); + + if (restaurants.length > 0) { + await tx.insert(schema.workersToRestaurants).values( + restaurants.map((r) => ({ + workerId: id, + restaurantId: r.restaurantId, + })), + ); + } + } + }); return await this.findById(id); } public async remove() {} + + /** + * Creates initial admin user if no workers exist in the database + */ + public async createInitialAdminIfNeeded(): Promise { + if ((await this.getTotalCount()) === 0) { + await this.create({ + login: "admin", + name: "Admin", + password: env.INITIAL_ADMIN_PASSWORD ?? "123456", + role: schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, + onlineAt: new Date(), + updatedAt: new Date(), + }); + } + } } diff --git a/src/workshifts/@/dto/create-workshift.dto.ts b/src/workshifts/@/dto/create-workshift.dto.ts new file mode 100644 index 0000000..b5cc8c5 --- /dev/null +++ b/src/workshifts/@/dto/create-workshift.dto.ts @@ -0,0 +1,6 @@ +import { PickType } from "@nestjs/swagger"; +import { WorkshiftEntity } from "src/workshifts/@/entity/workshift.entity"; + +export class CreateWorkshiftDto extends PickType(WorkshiftEntity, [ + "restaurantId", +]) {} diff --git a/src/workshifts/@/entity/workshift-navigation.entity.ts b/src/workshifts/@/entity/workshift-navigation.entity.ts new file mode 100644 index 0000000..08044c1 --- /dev/null +++ b/src/workshifts/@/entity/workshift-navigation.entity.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsUUID } from "@i18n-class-validator"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class WorkshiftNavigationEntity { + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "ID of the previous workshift", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + prevId: string | null; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "ID of the next workshift", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + nextId: string | null; +} diff --git a/src/workshifts/@/entity/workshift.entity.ts b/src/workshifts/@/entity/workshift.entity.ts new file mode 100644 index 0000000..6f020c1 --- /dev/null +++ b/src/workshifts/@/entity/workshift.entity.ts @@ -0,0 +1,133 @@ +import { IsDate, IsEnum, IsOptional, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { + IWorkshift, + ZodWorkshiftStatus, +} from "@postgress-db/schema/workshifts"; +import { Expose, Type } from "class-transformer"; +import { RestaurantEntity } from "src/restaurants/@/entities/restaurant.entity"; +import { WorkerEntity } from "src/workers/entities/worker.entity"; + +export class WorkshiftRestaurantEntity extends PickType(RestaurantEntity, [ + "id", + "name", + "currency", +]) {} + +export class WorkshiftWorkerEntity extends PickType(WorkerEntity, [ + "id", + "name", + "role", +]) {} + +export class WorkshiftEntity implements IWorkshift { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the workshift", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsEnum(ZodWorkshiftStatus.Enum) + @Expose() + @ApiProperty({ + description: "Status of the workshift", + enum: ZodWorkshiftStatus.Enum, + example: ZodWorkshiftStatus.Enum.PLANNED, + examples: Object.values(ZodWorkshiftStatus.Enum), + }) + status: typeof ZodWorkshiftStatus._type; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Restaurant identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + restaurantId: string; + + @Expose() + @Type(() => WorkshiftRestaurantEntity) + @ApiProperty({ + description: "Restaurant", + type: WorkshiftRestaurantEntity, + }) + restaurant: WorkshiftRestaurantEntity; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Worker who opened the workshift", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + openedByWorkerId: string | null; + + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Worker who closed the workshift", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + closedByWorkerId: string | null; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Date when workshift was created", + example: new Date(), + }) + createdAt: Date; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Date when workshift was last updated", + example: new Date(), + }) + updatedAt: Date; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Date when workshift was opened", + example: null, + }) + openedAt: Date | null; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Date when workshift was closed", + example: null, + }) + closedAt: Date | null; + + @Expose() + @Type(() => WorkshiftWorkerEntity) + @ApiPropertyOptional({ + description: "Worker who opened the workshift", + type: WorkshiftWorkerEntity, + }) + openedByWorker?: WorkshiftWorkerEntity | null; + + @Expose() + @Type(() => WorkshiftWorkerEntity) + @ApiPropertyOptional({ + description: "Worker who closed the workshift", + type: WorkshiftWorkerEntity, + }) + closedByWorker?: WorkshiftWorkerEntity | null; + + @Expose() + @Type(() => WorkerEntity) + @ApiProperty({ + description: "Workers assigned to this workshift", + type: [WorkerEntity], + }) + workers?: WorkerEntity[]; +} diff --git a/src/workshifts/@/entity/workshifts-paginated.entity.ts b/src/workshifts/@/entity/workshifts-paginated.entity.ts new file mode 100644 index 0000000..e2b6d88 --- /dev/null +++ b/src/workshifts/@/entity/workshifts-paginated.entity.ts @@ -0,0 +1,15 @@ +import { PaginationResponseDto } from "@core/dto/pagination-response.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; + +import { WorkshiftEntity } from "./workshift.entity"; + +export class WorkshiftsPaginatedEntity extends PaginationResponseDto { + @Expose() + @ApiProperty({ + description: "Array of workshifts", + type: [WorkshiftEntity], + }) + @Type(() => WorkshiftEntity) + data: WorkshiftEntity[]; +} diff --git a/src/workshifts/@/services/workshifts.service.ts b/src/workshifts/@/services/workshifts.service.ts new file mode 100644 index 0000000..8263b14 --- /dev/null +++ b/src/workshifts/@/services/workshifts.service.ts @@ -0,0 +1,382 @@ +import { + IPagination, + PAGINATION_DEFAULT_LIMIT, +} from "@core/decorators/pagination.decorator"; +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { workshifts } from "@postgress-db/schema/workshifts"; +import { and, count, desc, eq, inArray, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { CreateWorkshiftDto } from "src/workshifts/@/dto/create-workshift.dto"; +import { WorkshiftEntity } from "src/workshifts/@/entity/workshift.entity"; + +import { WorkshiftNavigationEntity } from "../entity/workshift-navigation.entity"; + +@Injectable() +export class WorkshiftsService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + /** + * Builds the where clause for the workshifts query based on the worker's role + * @param worker - The worker who is requesting the workshifts + * @returns The where clause for the workshifts query + */ + private _buildWorkerWhere(worker: RequestWorker) { + const conditions: SQL[] = []; + + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + } else if (worker.role === "OWNER") { + // For owner, we only want to see workshifts for their restaurants + conditions.push( + inArray( + workshifts.restaurantId, + worker.ownedRestaurants.map((r) => r.id), + ), + ); + } else if (worker.role === "ADMIN" || worker.role === "CASHIER") { + // Only assigned to worker restaurants + conditions.push( + inArray( + workshifts.restaurantId, + worker.workersToRestaurants.map((r) => r.restaurantId), + ), + ); + } else { + throw new ForbiddenException(); + } + + return conditions; + } + + public async getTotalCount(options: { + worker: RequestWorker; + restaurantId?: string; + }): Promise { + const { worker, restaurantId } = options; + + const conditions: SQL[] = [...this._buildWorkerWhere(worker)]; + + if (restaurantId) { + conditions.push(eq(workshifts.restaurantId, restaurantId)); + } + + const query = this.pg + .select({ + value: count(), + }) + .from(workshifts); + + if (conditions.length > 0) { + query.where(and(...conditions)); + } + + return await query.then((res) => res[0].value); + } + + /** + * Finds a workshift by id + * @param options - Options for finding a workshift + * @param options.worker - Worker who is requesting the workshift + * @param options.id - Id of the workshift + * @returns The found workshift or null if not found + */ + public async findOne( + id: string, + options: { + worker: RequestWorker; + }, + ): Promise { + const { worker } = options; + + const conditions: SQL[] = [ + // Worker + ...this._buildWorkerWhere(worker), + eq(workshifts.id, id), + ]; + + const result = await this.pg.query.workshifts.findFirst({ + where: (_, { and }) => and(...conditions), + with: { + restaurant: { + columns: { + id: true, + name: true, + currency: true, + }, + }, + openedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + closedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + }, + }); + + return result ?? null; + } + + /** + * Finds all workshifts + * @param options - Options for finding workshifts + * @param options.worker - Worker who is requesting the workshifts + * @param options.pagination - Pagination options + * @returns Array of workshifts + */ + public async findMany(options: { + worker: RequestWorker; + pagination?: IPagination; + restaurantId?: string; + }): Promise { + const { worker, pagination, restaurantId } = options; + + const conditions: SQL[] = [ + // Worker + ...this._buildWorkerWhere(worker), + ]; + + if (restaurantId) { + conditions.push(eq(workshifts.restaurantId, restaurantId)); + } + + const result = await this.pg.query.workshifts.findMany({ + where: (_, { and }) => and(...conditions), + with: { + restaurant: { + columns: { + id: true, + name: true, + currency: true, + }, + }, + openedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + closedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + }, + orderBy: [desc(workshifts.createdAt), desc(workshifts.id)], + limit: pagination?.size ?? PAGINATION_DEFAULT_LIMIT, + offset: pagination?.offset ?? 0, + }); + + return result; + } + + /** + * Checks if the worker has enough rights to perform the action + * @param worker - Worker who is performing the action + * @param restaurantId - Id of the restaurant + */ + private _checkRights(worker: RequestWorker, restaurantId: string) { + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + } else if (worker.role === "OWNER") { + // Check if worker owns the restaurant + if (!worker.ownedRestaurants.some((r) => r.id === restaurantId)) { + throw new ForbiddenException("errors.workshifts.not-enough-rights"); + } + } else if (worker.role === "ADMIN" || worker.role === "CASHIER") { + // Check if worker is assigned to the restaurant + if ( + !worker.workersToRestaurants.some( + (r) => r.restaurantId === restaurantId, + ) + ) { + throw new ForbiddenException("errors.workshifts.not-enough-rights"); + } + } else { + throw new ForbiddenException("errors.workshifts.not-enough-rights"); + } + } + + /** + * Creates a new workshift + * @param payload - The payload for creating a workshift + * @param opts - Options for creating a workshift + * @param opts.worker - Worker who is creating the workshift + * @returns The created workshift + */ + public async create( + payload: CreateWorkshiftDto, + opts: { worker: RequestWorker }, + ): Promise { + const { restaurantId } = payload; + const { worker } = opts; + + const restaurant = await this.pg.query.restaurants.findFirst({ + where: (restaurants, { and, eq }) => + and( + eq(restaurants.id, restaurantId), + // Check that restaurant is enabled and not closed forever + eq(restaurants.isEnabled, true), + eq(restaurants.isClosedForever, false), + ), + }); + + if (!restaurant) { + throw new NotFoundException("errors.workshifts.restaurant-not-available"); + } + + // Check if worker has enough rights to create workshift for this restaurant + this._checkRights(worker, restaurantId); + + const [prevWorkshift] = await this.pg.query.workshifts.findMany({ + where: (_, { and, eq }) => and(eq(workshifts.restaurantId, restaurantId)), + columns: { + status: true, + }, + orderBy: [desc(workshifts.createdAt)], + limit: 1, + }); + + // Prev is opened, so we can't create new one + if (prevWorkshift && prevWorkshift.status === "OPENED") { + throw new BadRequestException( + "errors.workshifts.close-previous-workshift", + ); + } + + const [createdWorkshift] = await this.pg + .insert(workshifts) + .values({ + status: "OPENED", + restaurantId, + openedByWorkerId: worker.id, + openedAt: new Date(), + }) + .returning({ + id: workshifts.id, + }); + + return (await this.findOne(createdWorkshift.id, { + worker, + })) as WorkshiftEntity; + } + + public async close( + workshiftId: string, + opts: { worker: RequestWorker }, + ): Promise { + const { worker } = opts; + + const workshift = await this.pg.query.workshifts.findFirst({ + where: (workshifts, { and, eq }) => and(eq(workshifts.id, workshiftId)), + columns: { + status: true, + restaurantId: true, + }, + }); + + if (!workshift) { + throw new NotFoundException(); + } + + // Check if worker has enough rights to create workshift for this restaurant + this._checkRights(worker, workshift.restaurantId); + + if (workshift.status === "CLOSED") { + throw new BadRequestException( + "errors.workshifts.workshift-already-closed", + ); + } + + await this.pg + .update(workshifts) + .set({ + status: "CLOSED", + closedByWorkerId: worker.id, + closedAt: new Date(), + }) + .where(eq(workshifts.id, workshiftId)); + + return (await this.findOne(workshiftId, { + worker, + })) as WorkshiftEntity; + } + + /** + * Gets the next and previous workshift IDs for a given workshift + * @param workshiftId - ID of the current workshift + * @param options - Options for finding navigation + * @param options.worker - Worker who is requesting the navigation + * @returns Object containing next and previous workshift IDs + */ + public async getNavigation( + workshiftId: string, + options: { + worker: RequestWorker; + }, + ): Promise { + const { worker } = options; + + const currentWorkshift = await this.findOne(workshiftId, { worker }); + + if (!currentWorkshift) { + throw new NotFoundException(); + } + + const conditions = [ + ...this._buildWorkerWhere(worker), + eq(workshifts.restaurantId, currentWorkshift.restaurantId), + ]; + + // Get previous workshift + const [prevWorkshift] = await this.pg.query.workshifts.findMany({ + where: (workshifts, { and, lt }) => + and( + ...conditions, + lt(workshifts.createdAt, currentWorkshift.createdAt), + lt(workshifts.id, currentWorkshift.id), + ), + columns: { + id: true, + }, + orderBy: [desc(workshifts.createdAt)], + limit: 1, + }); + + // Get next workshift + const [nextWorkshift] = await this.pg.query.workshifts.findMany({ + where: (workshifts, { and, gt }) => + and( + ...conditions, + gt(workshifts.createdAt, currentWorkshift.createdAt), + gt(workshifts.id, currentWorkshift.id), + ), + columns: { + id: true, + }, + orderBy: [workshifts.createdAt], + limit: 1, + }); + + return { + prevId: prevWorkshift?.id ?? null, + nextId: nextWorkshift?.id ?? null, + }; + } +} diff --git a/src/workshifts/@/workshifts.controller.ts b/src/workshifts/@/workshifts.controller.ts new file mode 100644 index 0000000..2be028a --- /dev/null +++ b/src/workshifts/@/workshifts.controller.ts @@ -0,0 +1,136 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { + IPagination, + PaginationParams, +} from "@core/decorators/pagination.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { StringValuePipe } from "@core/pipes/string.pipe"; +import { Body, Get, Param, Post, Query } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation, ApiQuery } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { CreateWorkshiftDto } from "src/workshifts/@/dto/create-workshift.dto"; +import { WorkshiftEntity } from "src/workshifts/@/entity/workshift.entity"; +import { WorkshiftsPaginatedEntity } from "src/workshifts/@/entity/workshifts-paginated.entity"; +import { WorkshiftsService } from "src/workshifts/@/services/workshifts.service"; + +import { WorkshiftNavigationEntity } from "./entity/workshift-navigation.entity"; + +@Controller("workshifts") +export class WorkshiftsController { + constructor(private readonly workshiftsService: WorkshiftsService) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(WorkshiftsPaginatedEntity) + @ApiOperation({ summary: "Get workshifts" }) + @ApiOkResponse({ + description: "Workshifts fetched successfully", + type: WorkshiftsPaginatedEntity, + }) + @ApiQuery({ + name: "restaurantId", + type: String, + required: false, + }) + async getWorkshifts( + @PaginationParams() pagination: IPagination, + @Worker() worker: RequestWorker, + @Query("restaurantId", new StringValuePipe()) + restaurantId?: string, + ): Promise { + const total = await this.workshiftsService.getTotalCount({ + worker, + restaurantId, + }); + + const data = await this.workshiftsService.findMany({ + worker, + pagination, + restaurantId, + }); + + return { + data, + meta: { + ...pagination, + total, + }, + }; + } + + @EnableAuditLog() + @Get(":workshiftId") + @Serializable(WorkshiftEntity) + @ApiOperation({ summary: "Get workshift by ID" }) + @ApiOkResponse({ + description: "Workshift fetched successfully", + type: WorkshiftEntity, + }) + async getWorkshift( + @Param("workshiftId") workshiftId: string, + @Worker() worker: RequestWorker, + ): Promise { + const workshift = await this.workshiftsService.findOne(workshiftId, { + worker, + }); + + if (!workshift) { + throw new NotFoundException(); + } + + return workshift; + } + + @EnableAuditLog() + @Post() + @Serializable(WorkshiftEntity) + @ApiOperation({ summary: "Create workshift" }) + @ApiOkResponse({ + description: "Workshift created successfully", + type: WorkshiftEntity, + }) + async createWorkshift( + @Body() payload: CreateWorkshiftDto, + @Worker() worker: RequestWorker, + ): Promise { + return await this.workshiftsService.create(payload, { + worker, + }); + } + + @EnableAuditLog() + @Post(":workshiftId/close") + @Serializable(WorkshiftEntity) + @ApiOperation({ summary: "Close workshift" }) + @ApiOkResponse({ + description: "Workshift closed successfully", + type: WorkshiftEntity, + }) + async closeWorkshift( + @Param("workshiftId") workshiftId: string, + @Worker() worker: RequestWorker, + ) { + return await this.workshiftsService.close(workshiftId, { + worker, + }); + } + + @Get(":workshiftId/navigation") + @Serializable(WorkshiftNavigationEntity) + @ApiOperation({ summary: "Get next and previous workshift IDs" }) + @ApiOkResponse({ + description: "Navigation IDs fetched successfully", + type: WorkshiftNavigationEntity, + }) + async getWorkshiftNavigation( + @Param("workshiftId") workshiftId: string, + @Worker() worker: RequestWorker, + ): Promise { + return await this.workshiftsService.getNavigation(workshiftId, { + worker, + }); + } +} diff --git a/src/workshifts/payments/dto/create-workshift-payment.dto.ts b/src/workshifts/payments/dto/create-workshift-payment.dto.ts new file mode 100644 index 0000000..a20b960 --- /dev/null +++ b/src/workshifts/payments/dto/create-workshift-payment.dto.ts @@ -0,0 +1,7 @@ +import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; +import { WorkshiftPaymentEntity } from "src/workshifts/payments/entity/workshift-payment.entity"; + +export class CreateWorkshiftPaymentDto extends IntersectionType( + PickType(WorkshiftPaymentEntity, ["categoryId", "amount"]), + PartialType(PickType(WorkshiftPaymentEntity, ["note"])), +) {} diff --git a/src/workshifts/payments/entity/workshift-payment.entity.ts b/src/workshifts/payments/entity/workshift-payment.entity.ts new file mode 100644 index 0000000..fe0b617 --- /dev/null +++ b/src/workshifts/payments/entity/workshift-payment.entity.ts @@ -0,0 +1,185 @@ +import { + IsBoolean, + IsDate, + IsDecimal, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { ZodCurrency } from "@postgress-db/schema/general"; +import { ZodWorkshiftPaymentType } from "@postgress-db/schema/workshift-enums"; +import { IWorkshiftPayment } from "@postgress-db/schema/workshift-payments"; +import { Expose, Type } from "class-transformer"; +import { WorkshiftPaymentCategoryEntity } from "src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity"; +import { WorkerEntity } from "src/workers/entities/worker.entity"; + +export class WorkshiftPaymentWorkerEntity extends PickType(WorkerEntity, [ + "id", + "name", + "role", +]) {} + +export class WorkshiftPaymentIncludedCategoryParentEntity extends PickType( + WorkshiftPaymentCategoryEntity, + ["name"], +) {} + +export class WorkshiftPaymentIncludedCategoryEntity extends PickType( + WorkshiftPaymentCategoryEntity, + ["name", "parentId"], +) { + @Expose() + @Type(() => WorkshiftPaymentIncludedCategoryParentEntity) + @ApiPropertyOptional({ + description: "Parent category of the payment", + type: WorkshiftPaymentIncludedCategoryParentEntity, + }) + parent: WorkshiftPaymentIncludedCategoryParentEntity | null; +} + +export class WorkshiftPaymentEntity implements IWorkshiftPayment { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the payment", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Category ID of the payment", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + categoryId: string; + + @Expose() + @Type(() => WorkshiftPaymentIncludedCategoryEntity) + @ApiProperty({ + description: "Category of the payment", + type: WorkshiftPaymentIncludedCategoryEntity, + }) + category: WorkshiftPaymentIncludedCategoryEntity; + + @IsEnum(ZodWorkshiftPaymentType.Enum) + @Expose() + @ApiProperty({ + description: "Type of the payment", + enum: ZodWorkshiftPaymentType.Enum, + example: ZodWorkshiftPaymentType.Enum.INCOME, + examples: Object.values(ZodWorkshiftPaymentType.Enum), + }) + type: typeof ZodWorkshiftPaymentType._type; + + @IsOptional() + @IsString() + @Expose() + @MaxLength(255) + @ApiPropertyOptional({ + description: "Note for the payment", + example: "Payment for extra hours", + }) + note: string | null; + + @IsDecimal() + @Expose() + @ApiProperty({ + description: "Amount of the payment", + example: "150.00", + }) + amount: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Currency of the payment", + example: ZodCurrency.Enum.USD, + enum: ZodCurrency.Enum, + examples: Object.values(ZodCurrency.Enum), + }) + currency: typeof ZodCurrency._type; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "ID of the workshift", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workshiftId: string; + + @IsOptional() + @IsUUID() + @Expose() + @ApiPropertyOptional({ + description: "ID of the worker", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + workerId: string | null; + + @Expose() + @IsOptional() + @Type(() => WorkshiftPaymentWorkerEntity) + @ApiPropertyOptional({ + description: "Worker who created the payment", + type: WorkshiftPaymentWorkerEntity, + }) + worker: WorkshiftPaymentWorkerEntity | null; + + @IsOptional() + @IsUUID() + @Expose() + @ApiPropertyOptional({ + description: "ID of the worker who removed the payment", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + removedByWorkerId: string | null; + + @Expose() + @IsOptional() + @Type(() => WorkshiftPaymentWorkerEntity) + @ApiPropertyOptional({ + description: "Worker who removed the payment", + type: WorkshiftPaymentWorkerEntity, + }) + removedByWorker: WorkshiftPaymentWorkerEntity | null; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether the payment is removed", + example: false, + }) + isRemoved: boolean; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Date when payment was created", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + createdAt: Date; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Date when payment was last updated", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + updatedAt: Date; + + @IsOptional() + @IsDate() + @Expose() + @ApiPropertyOptional({ + description: "Date when payment was removed", + example: new Date("2021-08-01T00:00:00.000Z"), + type: Date, + }) + removedAt: Date | null; +} diff --git a/src/workshifts/payments/services/workshift-payments.service.ts b/src/workshifts/payments/services/workshift-payments.service.ts new file mode 100644 index 0000000..4f6830b --- /dev/null +++ b/src/workshifts/payments/services/workshift-payments.service.ts @@ -0,0 +1,270 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { WorkshiftPaymentType } from "@postgress-db/schema/workshift-enums"; +import { workshiftPaymentCategories } from "@postgress-db/schema/workshift-payment-category"; +import { workshiftPayments } from "@postgress-db/schema/workshift-payments"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { WorkshiftEntity } from "src/workshifts/@/entity/workshift.entity"; +import { CreateWorkshiftPaymentDto } from "src/workshifts/payments/dto/create-workshift-payment.dto"; +import { WorkshiftPaymentEntity } from "src/workshifts/payments/entity/workshift-payment.entity"; + +@Injectable() +export class WorkshiftPaymentsService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + /** + * Checks if the worker has enough rights to manipulate with workshift + * @param workshift - The workshift to check rights for + * @param worker - The worker to check rights for + */ + private async _checkRights( + workshift: Pick, + worker: RequestWorker, + ) { + // SYSTEM_ADMIN or CHIEF_ADMIN can do anything + if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { + } + // CHECK OWNERSHIP + else if (worker.role === "OWNER") { + if ( + !worker.ownedRestaurants.some((r) => r.id === workshift.restaurantId) + ) { + throw new BadRequestException( + "errors.workshift-payments.not-enough-rights", + ); + } + } + // CHECK ASSIGNMENT + else if (worker.role === "ADMIN" || worker.role === "CASHIER") { + if ( + !worker.workersToRestaurants.some( + (r) => r.restaurantId === workshift.restaurantId, + ) + ) { + throw new BadRequestException( + "errors.workshift-payments.not-enough-rights", + ); + } + + // Only admins and owner can manipulate with closed workshifts + if (worker.role === "CASHIER" && workshift.closedAt) { + throw new BadRequestException( + "errors.workshift-payments.workshift-already-closed", + ); + } + } else { + throw new BadRequestException( + "errors.workshift-payments.not-enough-rights", + ); + } + } + + /** + * Finds all workshift payments for a given workshift + * @param options - The options to find the workshift payments + * @returns The found workshift payments + */ + async findMany(options: { + worker: RequestWorker; + workshiftId: string; + types?: WorkshiftPaymentType[]; + }): Promise { + const { worker, workshiftId, types } = options; + + const workshift = await this.pg.query.workshifts.findFirst({ + where: (workshift, { eq }) => eq(workshift.id, workshiftId), + columns: { + restaurantId: true, + closedAt: true, + }, + }); + + if (!workshift) { + throw new BadRequestException( + "errors.workshift-payments.workshift-not-found", + ); + } + + // Check rights + await this._checkRights(workshift, worker); + + const payments = await this.pg.query.workshiftPayments.findMany({ + where: (payment, { and, eq, inArray }) => + and( + eq(payment.workshiftId, workshiftId), + types ? inArray(payment.type, types) : undefined, + ), + with: { + category: { + with: { + parent: { + columns: { + name: true, + }, + }, + }, + columns: { + name: true, + parentId: true, + }, + }, + worker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + removedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + }, + orderBy: (payment, { desc }) => [desc(payment.createdAt)], + }); + + return payments; + } + + /** + * Creates a new workshift payment + * @param payload - The payload to create the workshift payment + * @param worker - The worker to create the workshift payment + * @returns The created workshift payment + */ + async create( + payload: CreateWorkshiftPaymentDto & { workshiftId: string }, + { worker }: { worker: RequestWorker }, + ) { + const { workshiftId } = payload; + + const workshift = await this.pg.query.workshifts.findFirst({ + where: (workshift, { eq }) => eq(workshift.id, workshiftId), + columns: { + restaurantId: true, + closedAt: true, + }, + with: { + restaurant: { + columns: { + currency: true, + }, + }, + }, + }); + + if (!workshift) { + throw new BadRequestException( + "errors.workshift-payments.workshift-not-found", + ); + } + + // Check rights + await this._checkRights(workshift, worker); + + const paymentCategory = + await this.pg.query.workshiftPaymentCategories.findFirst({ + where: (paymentCategory, { and, eq, notExists }) => + and( + eq(paymentCategory.id, payload.categoryId), + notExists( + this.pg + .select({ + id: workshiftPaymentCategories.id, + }) + .from(workshiftPaymentCategories) + .where( + eq(workshiftPaymentCategories.parentId, paymentCategory.id), + ), + ), + ), + + columns: { + type: true, + }, + }); + + if (!paymentCategory) { + throw new BadRequestException( + "errors.workshift-payments.payment-category-not-found", + ); + } + + const { categoryId, amount, note } = payload; + + const [payment] = await this.pg + .insert(workshiftPayments) + .values({ + categoryId, + type: paymentCategory.type, + amount, + currency: workshift.restaurant.currency, + note, + workshiftId, + workerId: worker.id, + }) + .returning(); + + return payment; + } + + /** + * Removes a workshift payment + * @param paymentId - The id of the payment to remove + * @param worker - The worker to remove the payment + * @returns The removed workshift payment + */ + async remove(paymentId: string, { worker }: { worker: RequestWorker }) { + const payment = await this.pg.query.workshiftPayments.findFirst({ + where: (payment, { eq }) => eq(payment.id, paymentId), + columns: { + workshiftId: true, + removedAt: true, + workerId: true, + }, + with: { + workshift: { + columns: { + restaurantId: true, + closedAt: true, + }, + }, + }, + }); + + if (!payment) { + throw new BadRequestException( + "errors.workshift-payments.payment-not-found", + ); + } + + if (payment.removedAt) { + throw new BadRequestException( + "errors.workshift-payments.payment-already-removed", + ); + } + + // Check rights + await this._checkRights(payment.workshift, worker); + + await this.pg + .update(workshiftPayments) + .set({ + isRemoved: true, + removedAt: new Date(), + removedByWorkerId: worker.id, + }) + .where(eq(workshiftPayments.id, paymentId)); + + return { id: paymentId }; + } +} diff --git a/src/workshifts/payments/workshift-payments.controller.ts b/src/workshifts/payments/workshift-payments.controller.ts new file mode 100644 index 0000000..9e9bcb9 --- /dev/null +++ b/src/workshifts/payments/workshift-payments.controller.ts @@ -0,0 +1,96 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { StringArrayPipe } from "@core/pipes/string-array.pipe"; +import { Body, Delete, Get, Param, Post, Query } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation, ApiQuery } from "@nestjs/swagger"; +import { WorkshiftPaymentType } from "@postgress-db/schema/workshift-enums"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { CreateWorkshiftPaymentDto } from "src/workshifts/payments/dto/create-workshift-payment.dto"; +import { WorkshiftPaymentEntity } from "src/workshifts/payments/entity/workshift-payment.entity"; +import { WorkshiftPaymentsService } from "src/workshifts/payments/services/workshift-payments.service"; + +@Controller("workshifts/:workshiftId/payments", { + tags: ["workshifts"], +}) +export class WorkshiftPaymentsController { + constructor( + private readonly workshiftPaymentsService: WorkshiftPaymentsService, + ) {} + + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(WorkshiftPaymentEntity) + @ApiOperation({ summary: "Get workshift payments" }) + @ApiOkResponse({ + description: "Workshift payments retrieved successfully", + type: [WorkshiftPaymentEntity], + }) + @ApiQuery({ + name: "types", + isArray: true, + enum: WorkshiftPaymentType, + example: [ + WorkshiftPaymentType.INCOME, + WorkshiftPaymentType.EXPENSE, + WorkshiftPaymentType.CASHLESS, + ], + required: false, + }) + async getWorkshiftPayments( + @Param("workshiftId") workshiftId: string, + @Worker() worker: RequestWorker, + @Query( + "types", + new StringArrayPipe({ + allowedValues: Object.values(WorkshiftPaymentType), + }), + ) + types?: WorkshiftPaymentType[], + ) { + return this.workshiftPaymentsService.findMany({ + worker, + workshiftId, + types, + }); + } + + @EnableAuditLog() + @Post() + @Serializable(WorkshiftPaymentEntity) + @ApiOperation({ summary: "Create workshift payment" }) + @ApiOkResponse({ + description: "Workshift payment created successfully", + type: WorkshiftPaymentEntity, + }) + async createWorkshiftPayment( + @Param("workshiftId") workshiftId: string, + @Worker() worker: RequestWorker, + @Body() payload: CreateWorkshiftPaymentDto, + ) { + return this.workshiftPaymentsService.create( + { + ...payload, + workshiftId, + }, + { worker }, + ); + } + + @EnableAuditLog() + @Delete(":paymentId") + @ApiOperation({ summary: "Delete workshift payment" }) + @ApiOkResponse({ + description: "Workshift payment deleted successfully", + }) + async deleteWorkshiftPayment( + @Param("workshiftId") workshiftId: string, + @Param("paymentId") paymentId: string, + @Worker() worker: RequestWorker, + ) { + return this.workshiftPaymentsService.remove(paymentId, { + worker, + }); + } +} diff --git a/src/workshifts/workshifts.module.ts b/src/workshifts/workshifts.module.ts new file mode 100644 index 0000000..a1d1995 --- /dev/null +++ b/src/workshifts/workshifts.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { SnapshotsModule } from "src/@base/snapshots/snapshots.module"; +import { WorkshiftsService } from "src/workshifts/@/services/workshifts.service"; +import { WorkshiftsController } from "src/workshifts/@/workshifts.controller"; +import { WorkshiftPaymentsService } from "src/workshifts/payments/services/workshift-payments.service"; +import { WorkshiftPaymentsController } from "src/workshifts/payments/workshift-payments.controller"; + +@Module({ + imports: [DrizzleModule, SnapshotsModule], + controllers: [WorkshiftsController, WorkshiftPaymentsController], + providers: [WorkshiftsService, WorkshiftPaymentsService], + exports: [WorkshiftsService], +}) +export class WorkshiftsModule {} diff --git a/test/helpers/clear-db.ts b/test/helpers/clear-db.ts deleted file mode 100644 index ace369c..0000000 --- a/test/helpers/clear-db.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { sql } from "drizzle-orm"; - -import { db } from "./db"; - -export const clearDatabase = async () => { - // truncate all tables - const tables = await db - .execute( - sql` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE'; - `, - ) - .then((res) => res.rows.map((row) => row.table_name)); - - await Promise.all( - tables.map((table) => - db.execute(sql`TRUNCATE TABLE ${sql.identifier(table)} CASCADE;`), - ), - ); -}; diff --git a/test/helpers/consts.ts b/test/helpers/consts.ts index 985b4d1..cec3b68 100644 --- a/test/helpers/consts.ts +++ b/test/helpers/consts.ts @@ -1,3 +1,3 @@ export const TEST_USER_AGENT = "test-user-agent"; export const TEST_IP_ADDRESS = "test-ip-address"; -export const TEST_PASSWORD = "test-password124ADff"; +export const TEST_PASSWORD = "12345678"; diff --git a/test/helpers/create-admin.ts b/test/helpers/create-admin.ts deleted file mode 100644 index fee10d8..0000000 --- a/test/helpers/create-admin.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { hash } from "argon2"; -import * as schema from "src/drizzle/schema"; - -import { TEST_PASSWORD } from "./consts"; -import { db } from "./db"; - -export const createAdmin = async () => { - if ((await db.query.workers.findMany()).length === 0) { - await db.insert(schema.workers).values({ - login: "admin", - passwordHash: await hash(TEST_PASSWORD), - role: schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, - }); - } -}; diff --git a/test/helpers/create-role-worker.ts b/test/helpers/create-role-worker.ts deleted file mode 100644 index 4a793cd..0000000 --- a/test/helpers/create-role-worker.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { hash } from "argon2"; -import * as schema from "src/drizzle/schema"; - -import { TEST_PASSWORD } from "./consts"; -import { db } from "./db"; - -export const createRoleWorker = async ( - role: schema.WorkerRole, -): Promise => { - const login = faker.internet.userName(); - - await db.insert(schema.workers).values({ - login, - passwordHash: await hash(TEST_PASSWORD), - role, - }); - - return login; -}; diff --git a/test/helpers/database/index.ts b/test/helpers/database/index.ts new file mode 100644 index 0000000..a0e292b --- /dev/null +++ b/test/helpers/database/index.ts @@ -0,0 +1,88 @@ +import childProcess from "child_process"; + +import { sql } from "drizzle-orm"; +import "dotenv/config"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; +import * as dishCategories from "src/@base/drizzle/schema/dish-categories"; +import * as dishes from "src/@base/drizzle/schema/dishes"; +import * as files from "src/@base/drizzle/schema/files"; +import * as general from "src/@base/drizzle/schema/general"; +import * as guests from "src/@base/drizzle/schema/guests"; +import * as manyToMany from "src/@base/drizzle/schema/many-to-many"; +import * as orderDeliveries from "src/@base/drizzle/schema/order-deliveries"; +import * as orderDishes from "src/@base/drizzle/schema/order-dishes"; +import * as orders from "src/@base/drizzle/schema/orders"; +import * as restaurantWorkshops from "src/@base/drizzle/schema/restaurant-workshop"; +import * as restaurants from "src/@base/drizzle/schema/restaurants"; +import * as sessions from "src/@base/drizzle/schema/sessions"; +import * as workers from "src/@base/drizzle/schema/workers"; + +export const schema = { + ...general, + ...restaurants, + ...sessions, + ...workers, + ...restaurantWorkshops, + ...guests, + ...dishes, + ...dishCategories, + ...manyToMany, + ...orderDeliveries, + ...orderDishes, + ...orders, + ...files, +}; + +export class DatabaseHelper { + constructor() {} + + public static pg = drizzle( + new Pool({ + connectionString: process.env.POSTGRESQL_URL, + }), + { schema }, + ); + + public static async truncateAll() { + console.log("Truncating all tables..."); + + const tables = await this.pg + .execute( + sql` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE'; + `, + ) + .then((res) => res.rows.map((row) => row.table_name as string)); + + for (const table of tables) { + console.log(`Truncating table ${table}`); + + await this.pg.execute( + sql`TRUNCATE TABLE ${sql.identifier(table)} CASCADE;`, + ); + } + } + + public static async migrate() { + console.log("Migrating database..."); + + await migrate(this.pg, { + migrationsFolder: "./src/drizzle/migrations", + }); + + console.log("Database migrated!"); + } + + public static async push() { + console.log("Pushing database..."); + + await childProcess.execSync("drizzle-kit push", { + stdio: "inherit", + }); + } +} diff --git a/test/helpers/db.ts b/test/helpers/db.ts deleted file mode 100644 index 53fb205..0000000 --- a/test/helpers/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; -import * as schema from "src/drizzle/schema"; - -export const db = drizzle( - new Pool({ connectionString: process.env.POSTGRESQL_URL }), - { schema }, -); - -export { schema }; diff --git a/test/helpers/migrate-db.ts b/test/helpers/migrate-db.ts deleted file mode 100644 index fe91689..0000000 --- a/test/helpers/migrate-db.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { migrate as _migrate } from "drizzle-orm/node-postgres/migrator"; - -import { db } from "./db"; - -export const migrate = async () => { - const result = await _migrate(db, { - migrationsFolder: "./src/drizzle/migrations", - }).catch((err) => { - console.error(err); - process.exit(1); - }); - - console.log("DB Migrated", result); -}; diff --git a/test/helpers/mock/workers.ts b/test/helpers/mock/workers.ts deleted file mode 100644 index d87e247..0000000 --- a/test/helpers/mock/workers.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { CreateWorkerDto } from "src/workers/dto/req/put-worker.dto"; - -import { TEST_PASSWORD } from "../consts"; -import { schema } from "../db"; - -export const mockWorkers = (length: number = 20): CreateWorkerDto[] => { - return Array.from({ length }, () => { - const name = faker.person.fullName(); - - return { - login: faker.internet.userName({ - firstName: name.split(" ")[0], - lastName: name.split(" ")[1], - }), - name, - isBlocked: false, - role: faker.helpers.arrayElement( - Object.values(schema.ZodWorkerRole.Enum).filter( - (role) => role !== schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, - ), - ), - hiredAt: faker.date.past(), - firedAt: faker.helpers.arrayElement([null, faker.date.past()]), - onlineAt: faker.date.recent(), - password: TEST_PASSWORD, - } as CreateWorkerDto; - }); -}; diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts deleted file mode 100644 index 71dabbb..0000000 --- a/test/helpers/seed.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as argon2 from "argon2"; -import { NodePgDatabase } from "drizzle-orm/node-postgres"; - -import { TEST_PASSWORD } from "./consts"; -import { db as defaultDb, schema } from "./db"; -import { mockWorkers } from "./mock/workers"; - -export const seedWorkers = async ( - db: NodePgDatabase, - amount: number, -) => { - console.log("Seeding workers..."); - const passwordHash = await argon2.hash(TEST_PASSWORD); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const data = mockWorkers(amount).map(({ password: _, ...worker }) => ({ - ...worker, - passwordHash, - })); - - await db.insert(schema.workers).values(data); -}; - -export const seedDatabase = async ( - db: NodePgDatabase = defaultDb, -) => { - await seedWorkers(db, 100); -}; diff --git a/test/helpers/seed/cache.json b/test/helpers/seed/cache.json new file mode 100644 index 0000000..aa31f10 --- /dev/null +++ b/test/helpers/seed/cache.json @@ -0,0 +1,112 @@ +{ + "addresses": [ + { + "city": "Kuressaare", + "address": "6CHQ+PV Nasva, Saare County, Estonia", + "latitude": "58.2293", + "longitude": "22.4397" + }, + { + "city": "Kuressaare", + "address": "Vana-Tänava, Parila, 93836 Saare maakond, Estonia", + "latitude": "58.2759", + "longitude": "22.4332" + }, + { + "city": "Pärnu", + "address": "9F2C+9H Pärnu, Pärnu County, Estonia", + "latitude": "58.3509", + "longitude": "24.4714" + }, + { + "city": "Kuressaare", + "address": "6F63+3J Nasva, Saare County, Estonia", + "latitude": "58.2102", + "longitude": "22.454" + }, + { + "city": "Tallinn", + "address": "CP2P+8H Tallinn, Harju County, Estonia", + "latitude": "59.4008", + "longitude": "24.7364" + }, + { + "city": "Tallinn", + "address": "9MRJ+526, 11614 Tallinn, Estonia", + "latitude": "59.3903", + "longitude": "24.6785" + }, + { + "city": "Tallinn", + "address": "Paljassaare põik 5, 10313 Tallinn, Estonia", + "latitude": "59.4631", + "longitude": "24.7037" + }, + { + "city": "Tallinn", + "address": "Smuuli viadukt, J. Smuuli tee, 11415 Tallinn, Estonia", + "latitude": "59.4223", + "longitude": "24.8413" + }, + { + "city": "Tallinn", + "address": "Nõlva tn 9, 10416 Tallinn, Estonia", + "latitude": "59.458", + "longitude": "24.7098" + }, + { + "city": "Pärnu", + "address": "9F6V+PF Pärnu, Pärnu County, Estonia", + "latitude": "58.3618", + "longitude": "24.4937" + }, + { + "city": "Tallinn", + "address": "FQFH+MX Tallinn, Harju County, Estonia", + "latitude": "59.4742", + "longitude": "24.7799" + }, + { + "city": "Tallinn", + "address": "Urva, 12012 Tallinn, Estonia", + "latitude": "59.4585", + "longitude": "24.8292" + }, + { + "city": "Pärnu", + "address": "9G7J+9F Pärnu, Pärnu County, Estonia", + "latitude": "58.3634", + "longitude": "24.5312" + }, + { + "city": "Pärnu", + "address": "8FJ6+5V Pärnu, Pärnu County, Estonia", + "latitude": "58.3304", + "longitude": "24.4622" + }, + { + "city": "Pärnu", + "address": "CH48+GH Kilksama, Pärnu County, Estonia", + "latitude": "58.4063", + "longitude": "24.5665" + }, + { + "city": "Kuressaare", + "address": "6G94+5G Kuressaare, Saare County, Estonia", + "latitude": "58.2179", + "longitude": "22.5063" + }, + { + "city": "Pärnu", + "address": "Haraka tn 31b, Pärnu, 80023 Pärnu maakond, Estonia", + "latitude": "58.3583", + "longitude": "24.5636" + }, + { + "city": "Kuressaare", + "address": "6FG3+MJ Nasva, Saare County, Estonia", + "latitude": "58.2267", + "longitude": "22.454" + } + ] +} \ No newline at end of file diff --git a/test/helpers/seed/dishes.ts b/test/helpers/seed/dishes.ts new file mode 100644 index 0000000..81802e8 --- /dev/null +++ b/test/helpers/seed/dishes.ts @@ -0,0 +1,24 @@ +import { faker } from "@faker-js/faker"; +import { DatabaseHelper, schema } from "test/helpers/database"; + +const mockDishes = async ( + count: number, +): Promise<(typeof schema.dishes.$inferInsert)[]> => { + return Array.from({ length: count }, () => { + return { + name: faker.commerce.productName(), + weight: faker.number.int({ min: 50, max: 200 }), + weightMeasure: "grams", + cookingTimeInMin: faker.number.int({ min: 10, max: 60 }), + note: faker.lorem.sentence(), + isPublishedAtSite: true, + isPublishedInApp: true, + } as typeof schema.dishes.$inferInsert; + }); +}; + +export default async function seedDishes(count: number) { + console.log("Seeding dishes..."); + const dishes = await mockDishes(count); + await DatabaseHelper.pg.insert(schema.dishes).values(dishes); +} diff --git a/test/helpers/seed/index.ts b/test/helpers/seed/index.ts new file mode 100644 index 0000000..6e87509 --- /dev/null +++ b/test/helpers/seed/index.ts @@ -0,0 +1,48 @@ +import { DatabaseHelper } from "test/helpers/database"; +import seedDishes from "test/helpers/seed/dishes"; +import seedOrders from "test/helpers/seed/orders"; +import seedRestaurants from "test/helpers/seed/restaurants"; +import seedWorkers, { createSystemAdmin } from "test/helpers/seed/workers"; + +import "dotenv/config"; + +interface SeedVariantData { + workers: number; + restaurants: number; + dishes: number; + orders: { + active: number; + archived: number; + removed: number; + delayed: number; + }; +} + +type SeedVariant = "mini"; + +const variants: Record = { + mini: { + workers: 50, + restaurants: 10, + dishes: 10, + orders: { + active: 10_000, + removed: 10_000, + archived: 80_000, + delayed: 1_000, + }, + }, +}; + +async function seed(variant: SeedVariantData) { + await DatabaseHelper.truncateAll(); + await DatabaseHelper.push(); + + await createSystemAdmin(); + await seedRestaurants(variant.restaurants); + await seedWorkers(variant.workers); + await seedDishes(variant.dishes); + await seedOrders(variant.orders); +} + +seed(variants.mini); diff --git a/test/helpers/seed/orders.ts b/test/helpers/seed/orders.ts new file mode 100644 index 0000000..c25da9e --- /dev/null +++ b/test/helpers/seed/orders.ts @@ -0,0 +1,194 @@ +import { faker } from "@faker-js/faker"; +import { DatabaseHelper, schema } from "test/helpers/database"; +import { v4 as uuidv4 } from "uuid"; + +const MAX_DISHES_PER_ORDER = 10; +const MIN_DISHES_PER_ORDER = 1; + +const mockOrders = async (count: number) => { + const restaurants = await DatabaseHelper.pg.select().from(schema.restaurants); + const guests = await DatabaseHelper.pg.select().from(schema.guests); + const dishes = await DatabaseHelper.pg.select().from(schema.dishes); + + let number = 0; + + return Array.from({ length: count }, () => { + number++; + + const orderId = uuidv4(); + const isWithGuestPhone = faker.datatype.boolean(); + + const isWithGuestId = faker.datatype.boolean(); + const guest = + guests.length > 0 && isWithGuestId + ? faker.helpers.arrayElement(guests) + : null; + + const isWithRestaurant = faker.datatype.boolean(); + const restaurant = + restaurants.length > 0 && isWithRestaurant + ? faker.helpers.arrayElement(restaurants) + : null; + + const type = faker.helpers.arrayElement([ + "hall", + "banquet", + "takeaway", + "delivery", + ]); + + const status = faker.helpers.arrayElement([ + "pending", + ...(!!restaurant + ? [ + "cooking", + "ready", + "paid", + "completed", + ...(type === "hall" || type === "banquet" ? ["delivering"] : []), + ] + : []), + "cancelled", + ]); + + const orderDishes = Array.from( + { + length: faker.number.int({ + min: MIN_DISHES_PER_ORDER, + max: MAX_DISHES_PER_ORDER, + }), + }, + () => { + const dish = faker.helpers.arrayElement(dishes); + const price = faker.number.float({ min: 1, max: 30 }); + + let dishStatus = "pending"; + + if (status === "cooking") { + dishStatus = faker.helpers.arrayElement(["cooking", "ready"]); + } + + if (status === "ready") { + dishStatus = faker.helpers.arrayElement(["ready", "completed"]); + } + + if ( + status === "delivering" || + status === "paid" || + status === "completed" + ) { + dishStatus = "completed"; + } + + return { + name: dish.name, + status: dishStatus, + dishId: dish.id, + orderId: orderId, + quantity: faker.number.int({ + min: 1, + max: 10, + }), + price: price.toString(), + finalPrice: price.toString(), + } as typeof schema.orderDishes.$inferInsert; + }, + ); + + return { + order: { + id: orderId, + from: faker.helpers.arrayElement(["app", "website", "internal"]), + currency: "EUR", + number: number.toString(), + type, + status, + ...(type === "hall" || type === "banquet" + ? { + tableNumber: `${faker.number.int({ min: 1, max: 10 })}.${faker.number.int({ min: 1, max: 10 })}`, + } + : {}), + guestName: guest?.name, + guestPhone: guest?.phone + ? guest.phone + : isWithGuestPhone + ? `+372${faker.string.numeric({ length: { min: 8, max: 8 } })}` + : null, + guestsAmount: faker.number.int({ min: 1, max: 10 }), + note: faker.lorem.sentence(), + restaurantId: restaurant?.id, + subtotal: orderDishes + .reduce((acc, dish) => acc + Number(dish.price), 0) + .toString(), + total: orderDishes + .reduce((acc, dish) => acc + Number(dish.finalPrice), 0) + .toString(), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + isArchived: false, + isRemoved: false, + } as typeof schema.orders.$inferInsert, + orderDishes, + }; + }); +}; + +export default async function seedOrders({ + active, + archived, + removed, + delayed, +}: { + active: number; + archived: number; + removed: number; + delayed: number; +}) { + console.log("Seeding orders..."); + + const totalCount = active + archived + removed + delayed; + const orders = await mockOrders(totalCount); + + orders.forEach((order, index) => { + if (index < delayed) { + order.order.status = "pending"; + order.order.delayedTo = faker.date.future(); + order.orderDishes.forEach((dish) => { + dish.status = "pending"; + }); + return; + } + if (index < delayed + active) { + return; + } + if (index < delayed + active + archived) { + order.order.isArchived = true; + order.order.removedAt = null; + } else { + order.order.isRemoved = true; + order.order.removedAt = faker.date.recent(); + } + }); + + const perOne = 1000; + const stagedOrders = [] as (typeof orders)[]; + + for (let i = 0; i < orders.length; i += perOne) { + const ordersToInsert = orders.slice(i, i + perOne); + stagedOrders.push(ordersToInsert); + } + + for (const ordersToInsert of stagedOrders) { + await DatabaseHelper.pg + .insert(schema.orders) + .values(ordersToInsert.map((order) => order.order)); + + await DatabaseHelper.pg + .insert(schema.orderDishes) + .values(ordersToInsert.flatMap((order) => order.orderDishes)); + } +} + +// await DatabaseHelper.pg +// .insert(schema.orderDishes) +// .values(orders.flatMap((order) => order.orderDishes)); diff --git a/test/helpers/seed/restaurants.ts b/test/helpers/seed/restaurants.ts new file mode 100644 index 0000000..e44c9bc --- /dev/null +++ b/test/helpers/seed/restaurants.ts @@ -0,0 +1,194 @@ +import * as fs from "fs/promises"; +import * as path from "path"; + +import { faker } from "@faker-js/faker"; +import { DatabaseHelper, schema } from "test/helpers/database"; + +interface City { + name: string; + coordinatesRange: { + latitude: [number, number]; + longitude: [number, number]; + }; + timezone: string; +} + +const cities: City[] = [ + { + name: "Tallinn", + coordinatesRange: { + latitude: [59.38, 59.5], + longitude: [24.65, 24.85], + }, + timezone: "Europe/Tallinn", + }, + { + name: "Pärnu", + coordinatesRange: { + latitude: [58.33, 58.42], + longitude: [24.45, 24.6], + }, + timezone: "Europe/Tallinn", + }, + { + name: "Kuressaare", + coordinatesRange: { + latitude: [58.21, 58.28], + longitude: [22.42, 22.52], + }, + timezone: "Europe/Tallinn", + }, +]; + +interface CachedAddress { + city: string; + address: string; + latitude: string; + longitude: string; +} + +interface AddressCache { + addresses: CachedAddress[]; +} + +const CACHE_FILE_PATH = path.join( + process.cwd(), + "test", + "helpers", + "seed", + "cache.json", +); + +// Track used addresses and new addresses in memory during the seeding session +const usedAddresses = new Set(); +let newAddresses: CachedAddress[] = []; + +async function loadOrCreateCache(): Promise { + try { + const cacheContent = await fs.readFile(CACHE_FILE_PATH, "utf-8"); + return JSON.parse(cacheContent); + } catch { + return { addresses: [] }; + } +} + +async function saveCache(cache: AddressCache): Promise { + await fs.writeFile(CACHE_FILE_PATH, JSON.stringify(cache, null, 2)); +} + +async function getGoogleAddress( + latitude: number, + longitude: number, +): Promise { + const apiKey = process.env?.GOOGLE_MAPS_API_KEY; + + if (!apiKey) { + return faker.location.streetAddress({ + useFullAddress: true, + }); + } + + try { + const response = await fetch( + `https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${apiKey}`, + ); + const data = await response.json(); + + if (data.results?.[0]?.formatted_address) { + return data.results[0].formatted_address; + } + + return faker.location.streetAddress({ useFullAddress: true }); + } catch (error) { + console.warn( + "Failed to fetch Google address, using faker fallback:", + error, + ); + return faker.location.streetAddress({ useFullAddress: true }); + } +} + +async function getOrCreateAddress(city: City): Promise { + const cache = await loadOrCreateCache(); + + // Try to find unused cached address for this city + const availableAddress = cache.addresses.find( + (addr) => addr.city === city.name && !usedAddresses.has(addr.address), + ); + + if (availableAddress) { + usedAddresses.add(availableAddress.address); + return availableAddress; + } + + // Generate new coordinates and get address from Google + const latitude = faker.location.latitude({ + min: city.coordinatesRange.latitude[0], + max: city.coordinatesRange.latitude[1], + }); + + const longitude = faker.location.longitude({ + min: city.coordinatesRange.longitude[0], + max: city.coordinatesRange.longitude[1], + }); + + const address = await getGoogleAddress(latitude, longitude); + + const newCachedAddress: CachedAddress = { + city: city.name, + address, + latitude: latitude.toString(), + longitude: longitude.toString(), + }; + + // Store new address in memory instead of saving immediately + newAddresses.push(newCachedAddress); + usedAddresses.add(address); + + return newCachedAddress; +} + +const mockRestaurants = async ( + count: number, +): Promise<(typeof schema.restaurants.$inferInsert)[]> => { + const restaurants = await Promise.all( + Array.from({ length: count }, async () => { + const name = faker.company.name(); + const estoniaLegalEntityCode = `EE${faker.string.numeric(8)}`; + const legalEntity = `${faker.company.name()}, ${estoniaLegalEntityCode}`; + const city = faker.helpers.arrayElement(cities); + + const addressData = await getOrCreateAddress(city); + + console.log("Seeding restaurant:", name, addressData.address); + + return { + name, + legalEntity, + address: addressData.address, + latitude: addressData.latitude, + longitude: addressData.longitude, + timezone: city.timezone, + isEnabled: true, + isClosedForever: false, + } as typeof schema.restaurants.$inferInsert; + }), + ); + + return restaurants; +}; + +export default async function seedRestaurants(count: number) { + console.log("Seeding restaurants..."); + const restaurants = await mockRestaurants(count); + await DatabaseHelper.pg.insert(schema.restaurants).values(restaurants); + + // After successful insert, update the cache file with new addresses + if (newAddresses.length > 0) { + const cache = await loadOrCreateCache(); + cache.addresses.push(...newAddresses); + await saveCache(cache); + // Reset new addresses array for next run + newAddresses = []; + } +} diff --git a/test/helpers/seed/workers.ts b/test/helpers/seed/workers.ts new file mode 100644 index 0000000..0023a8e --- /dev/null +++ b/test/helpers/seed/workers.ts @@ -0,0 +1,65 @@ +import { faker } from "@faker-js/faker"; +import * as argon2 from "argon2"; +import { DatabaseHelper, schema } from "test/helpers/database"; + +import { TEST_PASSWORD } from "../consts"; + +export const mockWorkers = async ( + length: number = 20, +): Promise<(typeof schema.workers.$inferInsert)[]> => { + const restaurants = await DatabaseHelper.pg + .select({ + id: schema.restaurants.id, + }) + .from(schema.restaurants); + + const passwordHash = await argon2.hash(TEST_PASSWORD); + return Array.from({ length }, () => { + const name = faker.person.fullName(); + const login = faker.internet + .userName({ + firstName: name.split(" ")[0], + lastName: name.split(" ")[1], + }) + .toLowerCase(); + + const role = faker.helpers.arrayElement( + Object.values(schema.ZodWorkerRole.Enum).filter( + (role) => role !== schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, + ), + ); + + let restaurantId: string | null = null; + + if (role !== schema.ZodWorkerRole.Enum.CHIEF_ADMIN) { + restaurantId = faker.helpers.arrayElement(restaurants.map((r) => r.id)); + } + + return { + name, + login, + passwordHash, + role, + onlineAt: faker.date.recent(), + hiredAt: faker.date.past(), + isBlocked: false, + restaurantId, + } as typeof schema.workers.$inferInsert; + }); +}; + +export default async function seedWorkers(count: number) { + console.log("Seeding workers..."); + const workers = await mockWorkers(count); + + await DatabaseHelper.pg.insert(schema.workers).values(workers); +} + +export const createSystemAdmin = async () => { + console.log("Creating system admin..."); + return await DatabaseHelper.pg.insert(schema.workers).values({ + login: "admin", + role: schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, + passwordHash: await argon2.hash(TEST_PASSWORD), + }); +}; diff --git a/test/helpers/sign-in.ts b/test/helpers/sign-in.ts index b4213fa..e53a4d7 100644 --- a/test/helpers/sign-in.ts +++ b/test/helpers/sign-in.ts @@ -3,13 +3,17 @@ import { eq } from "drizzle-orm"; import * as request from "supertest"; import { TEST_IP_ADDRESS, TEST_PASSWORD, TEST_USER_AGENT } from "./consts"; -import { db, schema } from "./db"; +import { DatabaseHelper, schema } from "./database"; export const signIn = async (login: string, app: INestApplication) => { - const worker = await db.query.workers.findFirst({ + const worker = await DatabaseHelper.pg.query.workers.findFirst({ where: eq(schema.workers.login, login), }); + if (!worker) { + throw new Error("Worker not found"); + } + await request(app.getHttpServer()) .post("/auth/sign-in") .set("user-agent", TEST_USER_AGENT) @@ -24,11 +28,11 @@ export const signIn = async (login: string, app: INestApplication) => { } }); - const session = await db.query.sessions.findFirst({ + const session = await DatabaseHelper.pg.query.sessions.findFirst({ where: eq(schema.sessions.workerId, worker.id), }); - await db.insert(schema.sessions).values({ + await DatabaseHelper.pg.insert(schema.sessions).values({ token: login, workerId: worker.id, httpAgent: TEST_USER_AGENT, diff --git a/test/multiple-run/.dockerignore b/test/multiple-run/.dockerignore new file mode 100644 index 0000000..db4c6d9 --- /dev/null +++ b/test/multiple-run/.dockerignore @@ -0,0 +1,2 @@ +dist +node_modules \ No newline at end of file diff --git a/test/multiple-run/Dockerfile b/test/multiple-run/Dockerfile new file mode 100644 index 0000000..b67b85d --- /dev/null +++ b/test/multiple-run/Dockerfile @@ -0,0 +1,29 @@ +FROM node:20-slim AS base + +WORKDIR /app + +# Copy package files +COPY package.json yarn.lock ./ + +# Install dependencies +RUN yarn install + +# Install nest CLI globally +RUN yarn global add @nestjs/cli + +FROM base AS runner +WORKDIR /app + +# COPY ../../src ./src +COPY ../../package.json ./package.json +COPY ../../yarn.lock ./yarn.lock +COPY ../../tsconfig.json ./tsconfig.json +COPY ../../tsconfig.build.json ./tsconfig.build.json +COPY ../../nest-cli.json ./nest-cli.json + +# We'll mount the app files instead of copying them +# This allows for live development +# The dist folder will be created separately for each instance + +# Use a shell to handle the dist directory cleanup +CMD ["yarn", "start:dev"] diff --git a/test/multiple-run/docker-compose.yml b/test/multiple-run/docker-compose.yml new file mode 100644 index 0000000..ddd0962 --- /dev/null +++ b/test/multiple-run/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3.8" + +services: + app1: + build: + context: ../.. + dockerfile: test/multiple-run/Dockerfile + target: runner + environment: + - PORT=3001 + env_file: + - ../../.env + ports: + - "3001:3001" + volumes: + - ../../src:/app/src:ro + network_mode: "host" + + # app2: + # build: + # context: ../.. + # dockerfile: test/multiple-run/Dockerfile + # target: runner + # env_file: + # - ../../.env + # environment: + # - PORT=3002 + # ports: + # - "3002:3002" + # network_mode: "host" + +volumes: + app1_node_modules: + app2_node_modules: diff --git a/test/socket.ts b/test/socket.ts new file mode 100644 index 0000000..b1462cf --- /dev/null +++ b/test/socket.ts @@ -0,0 +1,65 @@ +import * as dotenv from "dotenv"; +import { io } from "socket.io-client"; + +dotenv.config(); + +// console.log(process.env.DEV_SECRET_KEY); +const secretKey = process.env.DEV_SECRET_KEY; +const runAmount = 2; +const workerId = "a0902800-06d9-406e-a38e-f5eff06001dc"; + +async function createSocketInstance(instanceId: number) { + const socket = io("http://localhost:3001", { + extraHeaders: { + "x-dev-secret-key": secretKey ?? "", + "x-dev-worker-id": `${workerId}`, + }, + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + timeout: 20000, + }); + + socket.on("connect", () => { + console.log(`[Instance ${instanceId}] Connected to socket`); + }); + + socket.on("connect_error", (error) => { + console.error(`[Instance ${instanceId}] Connection error:`, error.message); + }); + + socket.on("reconnect_attempt", (attemptNumber) => { + console.log( + `[Instance ${instanceId}] Attempting to reconnect... (attempt ${attemptNumber})`, + ); + }); + + socket.on("disconnect", (reason) => { + console.log(`[Instance ${instanceId}] Disconnected from socket: ${reason}`); + }); + + socket.onAny((eventName, ...args) => { + console.log( + `[Instance ${instanceId}] Event "${eventName}" received:`, + ...args, + ); + }); + + return socket; +} + +async function main() { + const sockets = await Promise.all( + Array.from({ length: runAmount }, (_, i) => createSocketInstance(i + 1)), + ); + + // Keep the process running + process.on("SIGINT", () => { + console.log("Closing socket connections..."); + sockets.forEach((socket) => socket.close()); + process.exit(0); + }); +} + +main().catch(console.error); diff --git a/tsconfig.json b/tsconfig.json index a64e353..c525e2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,15 +12,17 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": false, + "strictNullChecks": true, "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, "paths": { "@core/*": ["src/@core/*"], - "@postgress-db/*": ["src/drizzle/*"], + "@i18n-class-validator": ["src/i18n/validators"], + "@postgress-db/*": ["src/@base/drizzle/*"], }, }, + "include": ["src/**/*", "test/**/*", "utils/**/*", "drizzle.config.ts"], "exclude": ["node_modules", "dist"], } diff --git a/utils/bench/data/requests.json b/utils/bench/data/requests.json new file mode 100644 index 0000000..9cc8435 --- /dev/null +++ b/utils/bench/data/requests.json @@ -0,0 +1,6 @@ +[ + "/dispatcher/orders", + "/dispatcher/orders/attention-required", + "/dispatcher/orders/delayed", + "/kitchener/orders" +] diff --git a/utils/bench/index.js b/utils/bench/index.js new file mode 100644 index 0000000..d3dc8f5 --- /dev/null +++ b/utils/bench/index.js @@ -0,0 +1,113 @@ +import { check, sleep } from "k6"; +import { SharedArray } from "k6/data"; +import { scenario } from "k6/execution"; +import http from "k6/http"; + +const requestsData = new SharedArray("requests", function () { + return JSON.parse(open("./data/requests.json")); +}); + +const host = __ENV.HOST || "http://localhost:6701"; +const username = "admin"; // Get username from env +const password = "123456"; // Get password from env + +export const options = { + stages: [ + { duration: "5s", target: 10 }, + { duration: "5s", target: 20 }, + { duration: "5s", target: 30 }, + { duration: "5s", target: 40 }, + { duration: "5s", target: 50 }, + { duration: "5s", target: 60 }, + { duration: "5s", target: 70 }, + { duration: "5s", target: 80 }, + { duration: "5s", target: 90 }, + { duration: "5s", target: 100 }, + { duration: "50s", target: 100 }, + { duration: "5s", target: 200 }, + { duration: "15s", target: 200 }, + { duration: "5s", target: 400 }, + { duration: "15s", target: 400 }, + { duration: "5s", target: 600 }, + { duration: "15s", target: 600 }, + { duration: "5s", target: 800 }, + { duration: "15s", target: 800 }, + { duration: "5s", target: 1000 }, + { duration: "15s", target: 1000 }, + { duration: "5s", target: 1200 }, + { duration: "15s", target: 1200 }, + { duration: "5s", target: 1400 }, + { duration: "15s", target: 1400 }, + { duration: "5s", target: 1600 }, + { duration: "15s", target: 1600 }, + { duration: "5s", target: 1800 }, + { duration: "15s", target: 1800 }, + { duration: "5s", target: 2000 }, + { duration: "15s", target: 2000 }, + { duration: "5s", target: 2200 }, + { duration: "15s", target: 2200 }, + { duration: "5s", target: 2400 }, + { duration: "15s", target: 2400 }, + { duration: "5s", target: 2600 }, + { duration: "15s", target: 2600 }, + { duration: "5s", target: 2800 }, + { duration: "15s", target: 2800 }, + { duration: "5s", target: 3000 }, + { duration: "55s", target: 3000 }, + ], +}; + +export function login(login, password) { + const url = `${host}/auth/sign-in`; + + const payload = JSON.stringify({ + login, + password, + }); + + const res = http.post(url, payload, { + headers: { + "Content-Type": "application/json", + "User-Agent": "k6-load-test/1.0", + "x-disable-session-refresh": "true", + }, + }); + + check(res, { + "Login successful": (r) => r.status === 200, + }); + + // Get the session cookie from response + const cookies = res.headers["Set-Cookie"]; + return cookies; +} + +export function setup() { + const cookies = login(username, password); + return { cookies }; +} + +export default function (data) { + const endpoint = requestsData[scenario.iterationInTest % requestsData.length]; + const url = `${host}${endpoint}`; + + const res = http.get(url, { + headers: { + Connection: "keep-alive", + "Keep-Alive": "timeout=5, max=1000", + "User-Agent": "k6-load-test/1.0", + "x-disable-session-refresh": "true", + Cookie: data.cookies, // Add the cookies to subsequent requests + }, + tags: { name: "fetch" }, + timeout: "30s", + }); + + if (res.status !== 200) { + console.log( + `Failed request to ${url}: ${res.status} ${res.body} ${res.request.cookies}`, + ); + } + + sleep(0.1 * (scenario.iterationInTest % 6)); +} diff --git a/utils/seed/.env.example b/utils/seed/.env.example new file mode 100644 index 0000000..1e538e4 --- /dev/null +++ b/utils/seed/.env.example @@ -0,0 +1,6 @@ +POSTGRES_CONTAINER_NAME=toite_test_database +POSTGRES_PORT=6000 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=postgres +POSTGRES_IMAGE=postgres:latest \ No newline at end of file diff --git a/utils/seed/chunker.ts b/utils/seed/chunker.ts new file mode 100644 index 0000000..4e32736 --- /dev/null +++ b/utils/seed/chunker.ts @@ -0,0 +1,13 @@ +export default function chunker(arr: T[], chunkSize: number) { + return arr.reduce((acc, item, index) => { + const chunkIndex = Math.floor(index / chunkSize); + + if (!acc[chunkIndex]) { + acc[chunkIndex] = []; + } + + acc[chunkIndex].push(item); + + return acc; + }, [] as T[][]); +} diff --git a/utils/seed/config.ts b/utils/seed/config.ts new file mode 100644 index 0000000..16bc0c5 --- /dev/null +++ b/utils/seed/config.ts @@ -0,0 +1,53 @@ +export type SeedConfig = { + restaurants: number; + restaurantOwners: number; + workers: number; + dishMenusPerOwner: number; + dishesPerMenu: number; + orders: { + justCreated: number; + justCreatedWithDishes: number; + sentToKitchen: number; + }; +}; + +export type SeedVariant = "mini" | "huge" | "insane"; + +export const seedConfigVariants: Record = { + mini: { + restaurants: 10, + restaurantOwners: 2, + workers: 100, + dishMenusPerOwner: 1, + dishesPerMenu: 50, + orders: { + justCreated: 100, + justCreatedWithDishes: 100, + sentToKitchen: 100, + }, + }, + huge: { + restaurants: 100, + restaurantOwners: 4, + workers: 4000, + dishMenusPerOwner: 2, + dishesPerMenu: 100, + orders: { + justCreated: 100_000, + justCreatedWithDishes: 100_000, + sentToKitchen: 100_000, + }, + }, + insane: { + restaurants: 100, + restaurantOwners: 4, + workers: 4000, + dishMenusPerOwner: 2, + dishesPerMenu: 100, + orders: { + justCreated: 1_000_000, + justCreatedWithDishes: 1_000_000, + sentToKitchen: 1_000_000, + }, + }, +}; diff --git a/utils/seed/db.ts b/utils/seed/db.ts new file mode 100644 index 0000000..baae861 --- /dev/null +++ b/utils/seed/db.ts @@ -0,0 +1,76 @@ +import dotenv from "dotenv"; +import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import * as discounts from "src/@base/drizzle/schema/discounts"; +import * as dishCategories from "src/@base/drizzle/schema/dish-categories"; +import * as dishModifiers from "src/@base/drizzle/schema/dish-modifiers"; +import * as dishes from "src/@base/drizzle/schema/dishes"; +import * as dishesMenus from "src/@base/drizzle/schema/dishes-menus"; +import * as files from "src/@base/drizzle/schema/files"; +import * as general from "src/@base/drizzle/schema/general"; +import * as guests from "src/@base/drizzle/schema/guests"; +import * as manyToMany from "src/@base/drizzle/schema/many-to-many"; +import * as orderDeliveries from "src/@base/drizzle/schema/order-deliveries"; +import * as orderDishes from "src/@base/drizzle/schema/order-dishes"; +import * as orderEnums from "src/@base/drizzle/schema/order-enums"; +import * as orderHistory from "src/@base/drizzle/schema/order-history"; +import * as orderPrechecks from "src/@base/drizzle/schema/order-prechecks"; +import * as orders from "src/@base/drizzle/schema/orders"; +import * as paymentMethods from "src/@base/drizzle/schema/payment-methods"; +import * as restaurantWorkshops from "src/@base/drizzle/schema/restaurant-workshop"; +import * as restaurants from "src/@base/drizzle/schema/restaurants"; +import * as sessions from "src/@base/drizzle/schema/sessions"; +import * as workers from "src/@base/drizzle/schema/workers"; +import * as workshiftEnums from "src/@base/drizzle/schema/workshift-enums"; +import * as workshiftPaymentCategories from "src/@base/drizzle/schema/workshift-payment-category"; +import * as workshiftPayments from "src/@base/drizzle/schema/workshift-payments"; +import * as workshifts from "src/@base/drizzle/schema/workshifts"; + +dotenv.config({ + path: "utils/seed/.env", +}); + +export const schema = { + ...general, + ...restaurants, + ...sessions, + ...workers, + ...restaurantWorkshops, + ...guests, + ...dishes, + ...dishCategories, + ...manyToMany, + ...files, + ...orderDishes, + ...orderDeliveries, + ...orderEnums, + ...orders, + ...orderPrechecks, + ...orderHistory, + ...paymentMethods, + ...dishModifiers, + ...discounts, + ...dishesMenus, + ...workshifts, + ...workshiftEnums, + ...workshiftPayments, + ...workshiftPaymentCategories, +}; + +export type Schema = typeof schema; +export type DrizzleDatabase = NodePgDatabase; +export type DrizzleTransaction = Parameters< + Parameters[0] +>[0]; + +const connectionString = `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@localhost:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`; + +const pool = new Pool({ + connectionString, +}); + +const db = drizzle(pool, { + schema, +}); + +export default db; diff --git a/utils/seed/drizzle.config.ts b/utils/seed/drizzle.config.ts new file mode 100644 index 0000000..86642c3 --- /dev/null +++ b/utils/seed/drizzle.config.ts @@ -0,0 +1,17 @@ +import * as dotenv from "dotenv"; +import type { Config } from "drizzle-kit"; + +dotenv.config({ + path: "utils/seed/.env", +}); + +const url = `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@localhost:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`; + +export default { + schema: "./src/@base/drizzle/schema", + out: "./src/@base/drizzle/migrations", + dialect: "postgresql", + dbCredentials: { + url, + }, +} satisfies Config; diff --git a/utils/seed/index.ts b/utils/seed/index.ts new file mode 100644 index 0000000..f963002 --- /dev/null +++ b/utils/seed/index.ts @@ -0,0 +1,542 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from "@faker-js/faker"; +import dotenv from "dotenv"; +import chunker from "utils/seed/chunker"; +import { seedConfigVariants } from "utils/seed/config"; +import db, { schema } from "utils/seed/db"; +import mockDishes, { mockDishMenus } from "utils/seed/mocks/dishes"; +import mockJustCreatedOrders, { + mockJustCreatedOrdersWithDishes, + mockSentToKitchenOrders, +} from "utils/seed/mocks/orders"; +import mockRestaurants, { + mockRestaurantDishModifiers, + mockRestaurantWorkshops, +} from "utils/seed/mocks/restaurants"; +import mockWorkers from "utils/seed/mocks/workers"; + +dotenv.config({ + path: "utils/seed/.env", +}); + +const CHUNK_SIZE = 4000; + +async function populateRestaurantsInfo(restaurantIds: string[]) { + const workingHours = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ].map( + (dayOfWeek) => + ({ + dayOfWeek, + closingTime: "23:00", + isEnabled: true, + openingTime: "10:00", + }) as typeof schema.restaurantHours.$inferInsert, + ); + + // Insert working hours for each restaurant + const restaurantHours = restaurantIds.flatMap((restaurantId) => + workingHours.map((hours) => ({ + ...hours, + restaurantId, + })), + ); + + const restaurantHoursChunks = chunker(restaurantHours, CHUNK_SIZE); + + for (const chunk of restaurantHoursChunks) { + await db.insert(schema.restaurantHours).values(chunk); + } + + // Insert workshops for each restaurant + const restaurantWorkshops = restaurantIds.flatMap((restaurantId) => + mockRestaurantWorkshops({ + restaurantId, + count: 5, + }), + ); + + const restaurantWorkshopsChunks = chunker(restaurantWorkshops, CHUNK_SIZE); + + for (const chunk of restaurantWorkshopsChunks) { + await db.insert(schema.restaurantWorkshops).values(chunk); + } + + const paymentMethods = ["Cash", "Card", "Online transfer"]; + + // Payment methods for each restaurant + const restaurantPaymentMethods = restaurantIds.flatMap((restaurantId) => + paymentMethods.map( + (name) => + ({ + restaurantId, + icon: "CARD", + name, + type: "CUSTOM", + isActive: true, + }) satisfies typeof schema.paymentMethods.$inferInsert, + ), + ); + + const restaurantPaymentMethodsChunks = chunker( + restaurantPaymentMethods, + CHUNK_SIZE, + ); + + for (const chunk of restaurantPaymentMethodsChunks) { + await db.insert(schema.paymentMethods).values(chunk); + } + + // Dish Modifiers + const restaurantDishModifiers = restaurantIds.flatMap((restaurantId) => + mockRestaurantDishModifiers({ + restaurantId, + count: 10, + }), + ); + + const restaurantDishModifiersChunks = chunker( + restaurantDishModifiers, + CHUNK_SIZE, + ); + + for (const chunk of restaurantDishModifiersChunks) { + await db.insert(schema.dishModifiers).values(chunk); + } +} + +async function assignRestaurantsToDishMenus() { + const dishMenus = await db.query.dishesMenus.findMany({ + columns: { + id: true, + ownerId: true, + }, + }); + + const restaurants = await db.query.restaurants.findMany({ + columns: { + id: true, + ownerId: true, + }, + }); + + const ownerIdToRestaurantIdsMap = new Map(); + + for (const restaurant of restaurants) { + if (!restaurant.ownerId) { + continue; + } + + const ownerId = restaurant.ownerId; + const restaurantId = restaurant.id; + + if (!ownerIdToRestaurantIdsMap.has(ownerId)) { + ownerIdToRestaurantIdsMap.set(ownerId, []); + } + + ownerIdToRestaurantIdsMap.get(ownerId)?.push(restaurantId); + } + + for (const dishMenu of dishMenus) { + const restaurantIds = ownerIdToRestaurantIdsMap.get(dishMenu.ownerId); + + if (!restaurantIds) { + continue; + } + + const dishesMenusToRestaurants = restaurantIds.map((restaurantId) => ({ + dishesMenuId: dishMenu.id, + restaurantId, + })); + + const dishesMenusToRestaurantsChunks = chunker( + dishesMenusToRestaurants, + CHUNK_SIZE, + ); + + for (const chunk of dishesMenusToRestaurantsChunks) { + await db.insert(schema.dishesMenusToRestaurants).values(chunk); + } + } +} + +async function populateDishesPricelist() { + const dishes = await db.query.dishes.findMany({ + columns: { + id: true, + menuId: true, + }, + with: { + menu: { + columns: {}, + with: { + dishesMenusToRestaurants: { + columns: { + restaurantId: true, + }, + with: { + restaurant: { + columns: { + currency: true, + }, + }, + }, + }, + }, + }, + }, + }); + + const dishesToRestaurants = dishes.flatMap((dish) => { + const price = faker.number.float({ min: 3, max: 22 }); + + return (dish.menu?.dishesMenusToRestaurants || []).map((menu) => { + return { + dishId: String(dish.id), + restaurantId: String(menu.restaurantId), + isInStopList: false, + currency: menu.restaurant.currency, + price: price.toString(), + } as typeof schema.dishesToRestaurants.$inferInsert; + }); + }); + + const dishesToRestaurantsChunks = chunker(dishesToRestaurants, CHUNK_SIZE); + + for (const chunk of dishesToRestaurantsChunks) { + await db.insert(schema.dishesToRestaurants).values(chunk); + } +} + +async function populateDishesWorkshopsRelations() { + const dishesToRestaurants = await db.query.dishesToRestaurants.findMany({ + columns: { + dishId: true, + restaurantId: true, + }, + }); + + const restaurantWorkshops = await db.query.restaurantWorkshops.findMany({ + columns: { + id: true, + restaurantId: true, + }, + }); + + const restaurantIdToWorkshopIdsMap = restaurantWorkshops.reduce( + (acc, workshop) => { + if (!acc?.[workshop.restaurantId]) { + acc[workshop.restaurantId] = []; + } + + acc[workshop.restaurantId].push(workshop.id); + + return acc; + }, + {} as Record, + ); + + // Populate dishes to workshops + + const dishesToWorkshops = dishesToRestaurants.flatMap((dishToRestaurant) => { + const allWorkshopIds = + restaurantIdToWorkshopIdsMap?.[dishToRestaurant.restaurantId] || []; + + const workshopIds = faker.helpers.arrayElements(allWorkshopIds || [], { + min: 1, + max: allWorkshopIds.length, + }); + + if (workshopIds.length === 0) { + return []; + } + + return (workshopIds || []).map( + (workshopId) => + ({ + dishId: dishToRestaurant.dishId, + workshopId, + }) satisfies typeof schema.dishesToWorkshops.$inferInsert, + ); + }); + + const dishesToWorkshopsChunks = chunker(dishesToWorkshops, CHUNK_SIZE); + + for (const chunk of dishesToWorkshopsChunks) { + await db.insert(schema.dishesToWorkshops).values(chunk); + } +} + +async function processBatchedMockData({ + totalCount, + batchSize = CHUNK_SIZE, + createMockBatch, + insertBatch, +}: { + totalCount: number; + batchSize?: number; + createMockBatch: (count: number) => Promise; + insertBatch: (items: T[]) => Promise; +}) { + const batchCount = Math.ceil(totalCount / batchSize); + + for (let i = 0; i < batchCount; i++) { + // For the last batch, only create the remaining items + const currentBatchSize = Math.min(batchSize, totalCount - i * batchSize); + if (currentBatchSize <= 0) break; + + // Create a batch of mock data + const mockBatch = await createMockBatch(currentBatchSize); + + // Insert the batch + await insertBatch(mockBatch); + } +} + +async function main() { + const config = seedConfigVariants["insane"]; + + const restaurantOwners = await mockWorkers({ + count: config.restaurantOwners, + role: "OWNER", + }); + + const systemAdmin = { + ...( + await mockWorkers({ + count: 1, + role: "SYSTEM_ADMIN", + }) + )[0], + login: "admin", + }; + + const workers = await mockWorkers({ + count: config.workers, + }); + + const restaurants = await mockRestaurants({ + count: config.restaurants, + ownerIds: restaurantOwners.map((owner) => owner.id), + }); + + // Insert workers first + const workersChunks = chunker( + [systemAdmin, ...workers, ...restaurantOwners], + CHUNK_SIZE, + ); + + for (const chunk of workersChunks) { + await db.insert(schema.workers).values(chunk); + } + + // Then time for restaurants + const restaurantsChunks = chunker(restaurants, CHUNK_SIZE); + + for (const chunk of restaurantsChunks) { + await db.insert(schema.restaurants).values(chunk); + } + + // Create restaurant things + await populateRestaurantsInfo(restaurants.map((r) => r.id)); + + // Assign workers to restaurants + const workersToRestaurantsChunks = chunker( + workers.map((worker) => { + const restaurant = faker.helpers.arrayElement(restaurants); + + return { + workerId: worker.id, + restaurantId: restaurant.id, + }; + }), + CHUNK_SIZE, + ); + + for (const chunk of workersToRestaurantsChunks) { + await db.insert(schema.workersToRestaurants).values(chunk); + } + + const dishMenus = mockDishMenus({ + ownerIds: restaurantOwners.map((owner) => owner.id), + count: config.dishMenusPerOwner, + }); + + // Create dish menus + const dishMenusChunks = chunker(dishMenus, CHUNK_SIZE); + + for (const chunk of dishMenusChunks) { + await db.insert(schema.dishesMenus).values(chunk); + } + + // Assign restaurants to dish menus + await assignRestaurantsToDishMenus(); + + const dishes = dishMenus.flatMap((dishMenu) => + mockDishes({ + menuId: dishMenu.id, + count: config.dishesPerMenu, + }), + ); + + // Create dishes + const dishesChunks = chunker(dishes, CHUNK_SIZE); + + for (const chunk of dishesChunks) { + await db.insert(schema.dishes).values(chunk); + } + + await populateDishesPricelist(); + await populateDishesWorkshopsRelations(); + + // Process just created orders in batches + await processBatchedMockData({ + totalCount: config.orders.justCreated, + createMockBatch: async (count) => await mockJustCreatedOrders({ count }), + insertBatch: async (orders) => { + const chunks = chunker(orders, CHUNK_SIZE); + const historyRecords = orders.map( + (order) => + ({ + orderId: order.id, + type: "created", + }) as typeof schema.orderHistoryRecords.$inferInsert, + ); + + const historyChunks = chunker(historyRecords, CHUNK_SIZE); + + await Promise.all([ + ...chunks.map(async (chunk) => { + return db.insert(schema.orders).values(chunk); + }), + ...historyChunks.map(async (chunk) => { + return db.insert(schema.orderHistoryRecords).values(chunk); + }), + ]); + }, + }); + + // Process just created orders with dishes in batches + await processBatchedMockData({ + totalCount: config.orders.justCreatedWithDishes, + createMockBatch: async (count) => + await mockJustCreatedOrdersWithDishes({ count }), + insertBatch: async (orders) => { + const chunks = chunker(orders, CHUNK_SIZE); + const historyRecords = orders.map( + (order) => + ({ + orderId: order.id, + type: "created", + }) as typeof schema.orderHistoryRecords.$inferInsert, + ); + + const historyChunks = chunker(historyRecords, CHUNK_SIZE); + + const orderDishes = orders.flatMap(({ orderDishes, ...order }) => + orderDishes.map((dish) => ({ ...dish })), + ); + + const dishesChunks = chunker(orderDishes, CHUNK_SIZE); + + await Promise.all([ + ...chunks.map(async (chunk) => { + return db.insert(schema.orders).values(chunk); + }), + ...historyChunks.map(async (chunk) => { + return db.insert(schema.orderHistoryRecords).values(chunk); + }), + ...dishesChunks.map(async (chunk) => { + return db.insert(schema.orderDishes).values(chunk); + }), + ]); + + // for (const chunk of chunks) { + // await db.insert(schema.orders).values(chunk); + // } + + // for (const chunk of historyChunks) { + // await db.insert(schema.orderHistoryRecords).values(chunk); + // } + }, + }); + + // Process sent to kitchen orders in batches + await processBatchedMockData({ + totalCount: config.orders.sentToKitchen, + createMockBatch: async (count) => await mockSentToKitchenOrders({ count }), + insertBatch: async (orders) => { + const chunks = chunker(orders, CHUNK_SIZE); + + // for (const chunk of chunks) { + // await db.insert(schema.orders).values(chunk); + // } + + // Created history records + const createdHistoryRecords = orders.map( + (order) => + ({ + orderId: order.id, + type: "created", + }) as typeof schema.orderHistoryRecords.$inferInsert, + ); + + const createdHistoryChunks = chunker(createdHistoryRecords, CHUNK_SIZE); + + // for (const chunk of createdHistoryChunks) { + // await db.insert(schema.orderHistoryRecords).values(chunk); + // } + + // Sent to kitchen history records + const sentToKitchenHistoryRecords = orders.map( + (order) => + ({ + orderId: order.id, + type: "sent_to_kitchen", + }) as typeof schema.orderHistoryRecords.$inferInsert, + ); + + const sentToKitchenHistoryChunks = chunker( + sentToKitchenHistoryRecords, + CHUNK_SIZE, + ); + + // for (const chunk of sentToKitchenHistoryChunks) { + // await db.insert(schema.orderHistoryRecords).values(chunk); + // } + + // Order dishes + const orderDishes = orders.flatMap(({ orderDishes, ...order }) => + orderDishes.map((dish) => ({ ...dish })), + ); + + const dishesChunks = chunker(orderDishes, CHUNK_SIZE); + + await Promise.all([ + ...chunks.map(async (chunk) => { + return db.insert(schema.orders).values(chunk); + }), + ...dishesChunks.map(async (chunk) => { + return db.insert(schema.orderDishes).values(chunk); + }), + ...createdHistoryChunks.map(async (chunk) => { + return db.insert(schema.orderHistoryRecords).values(chunk); + }), + ...sentToKitchenHistoryChunks.map(async (chunk) => { + return db.insert(schema.orderHistoryRecords).values(chunk); + }), + ]); + // for (const chunk of dishesChunks) { + // await db.insert(schema.orderDishes).values(chunk); + // } + }, + }); +} + +main(); diff --git a/utils/seed/mocks/dishes.ts b/utils/seed/mocks/dishes.ts new file mode 100644 index 0000000..ca27687 --- /dev/null +++ b/utils/seed/mocks/dishes.ts @@ -0,0 +1,44 @@ +import { faker } from "@faker-js/faker"; +import { schema } from "utils/seed/db"; +import { v4 as uuidv4 } from "uuid"; + +export function mockDishMenus(opts: { ownerIds: string[]; count: number }) { + const { count, ownerIds } = opts; + + return ownerIds + .map((ownerId) => + Array.from( + { length: count }, + () => + ({ + id: uuidv4(), + name: faker.commerce.productName(), + ownerId, + }) satisfies typeof schema.dishesMenus.$inferInsert, + ), + ) + .flat(); +} + +export default function mockDishes(opts: { menuId: string; count: number }) { + const { count, menuId } = opts; + + return Array.from( + { length: count }, + () => + ({ + id: uuidv4(), + name: faker.commerce.productName(), + amountPerItem: faker.number.int({ min: 1, max: 10 }), + cookingTimeInMin: faker.number.int({ min: 1, max: 100 }), + printLabelEveryItem: faker.number.int({ min: 1, max: 10 }), + isPublishedAtSite: true, + isPublishedInApp: true, + isLabelPrintingEnabled: faker.datatype.boolean(), + weight: faker.number.int({ min: 1, max: 1000 }), + weightMeasure: "grams", + note: faker.lorem.sentence(), + menuId, + }) satisfies typeof schema.dishes.$inferInsert, + ); +} diff --git a/utils/seed/mocks/orders.ts b/utils/seed/mocks/orders.ts new file mode 100644 index 0000000..999a561 --- /dev/null +++ b/utils/seed/mocks/orders.ts @@ -0,0 +1,178 @@ +import { faker } from "@faker-js/faker"; +import db, { schema } from "utils/seed/db"; +import { v4 as uuidv4 } from "uuid"; + +export type Order = typeof schema.orders.$inferSelect; + +let orderNumber = 0; + +export default async function mockJustCreatedOrders(opts: { count: number }) { + const { count } = opts; + + const restaurants = await db.query.restaurants.findMany({ + columns: { + id: true, + currency: true, + }, + with: { + paymentMethods: { + where: (paymentMethods, { eq }) => eq(paymentMethods.type, "CUSTOM"), + columns: { + id: true, + }, + }, + }, + }); + + return Array.from({ length: count }, () => { + const restaurant = faker.helpers.arrayElement(restaurants); + + orderNumber++; + + const from = faker.helpers.arrayElement([ + "app", + "internal", + "website", + ] as Order["from"][]); + + const type = faker.helpers.arrayElement([ + "hall", + "banquet", + "takeaway", + "delivery", + ] as Order["type"][]); + + const paymentMethodId = faker.helpers.arrayElement( + restaurant.paymentMethods.map((paymentMethod) => paymentMethod.id), + ); + + return { + id: uuidv4(), + number: orderNumber.toString(), + restaurantId: restaurant.id, + currency: restaurant.currency, + from, + status: "pending", + type, + guestName: faker.person.firstName(), + note: faker.lorem.sentence(), + paymentMethodId, + guestsAmount: faker.number.int({ min: 1, max: 10 }), + ...((type === "banquet" || type === "hall") && { + tableNumber: faker.number.int({ min: 1, max: 100000 }).toString(), + }), + createdAt: faker.date.recent(), + } satisfies typeof schema.orders.$inferInsert; + }); +} + +export async function mockJustCreatedOrdersWithDishes(opts: { count: number }) { + const { count } = opts; + + const dishesMenusToRestaurants = + await db.query.dishesMenusToRestaurants.findMany({ + columns: { + dishesMenuId: true, + restaurantId: true, + }, + with: { + dishesMenu: { + columns: {}, + with: { + dishes: { + columns: { + id: true, + name: true, + }, + with: { + dishesToRestaurants: { + columns: { + price: true, + dishId: true, + }, + }, + }, + }, + }, + }, + }, + }); + + const restaurantIdToMenu = new Map( + dishesMenusToRestaurants.map((dm) => [dm.restaurantId, dm.dishesMenu]), + ); + + const justCreatedOrders = await mockJustCreatedOrders({ count }); + + return justCreatedOrders.map((order) => { + const dishesMenu = restaurantIdToMenu.get(order.restaurantId); + + const someDishes = faker.helpers.arrayElements(dishesMenu?.dishes || [], { + min: 3, + max: 11, + }); + + const orderDishes = someDishes + .map((dish) => { + const price = dish.dishesToRestaurants?.[0]?.price; + + if (!price) return null; + + return { + dishId: dish.id, + name: dish.name, + orderId: order.id, + quantity: faker.number.int({ min: 1, max: 10 }), + status: "pending", + price, + + finalPrice: price, + } satisfies typeof schema.orderDishes.$inferInsert; + }) + .filter(Boolean) as (typeof schema.orderDishes.$inferInsert)[]; + + const orderDishesPrices = orderDishes.reduce( + (acc, dish) => ({ + price: acc.price + Number(dish.price) * dish.quantity, + finalPrice: acc.finalPrice + Number(dish.finalPrice) * dish.quantity, + }), + { price: 0, finalPrice: 0 }, + ); + + return { + ...order, + total: orderDishesPrices.finalPrice.toString(), + subtotal: orderDishesPrices.price.toString(), + orderDishes, + } satisfies typeof schema.orders.$inferInsert & { + orderDishes: (typeof schema.orderDishes.$inferInsert)[]; + }; + }); +} + +export async function mockSentToKitchenOrders(opts: { count: number }) { + const { count } = opts; + + const justCreatedOrdersWithDishes = await mockJustCreatedOrdersWithDishes({ + count, + }); + + return justCreatedOrdersWithDishes.map((order) => { + const cookingAt = faker.date.recent({ + refDate: order.createdAt, + }); + + return { + ...order, + status: "cooking", + orderDishes: order.orderDishes.map((dish) => ({ + ...dish, + status: "cooking", + cookingAt, + })), + cookingAt, + } satisfies typeof schema.orders.$inferInsert & { + orderDishes: (typeof schema.orderDishes.$inferInsert)[]; + }; + }); +} diff --git a/utils/seed/mocks/restaurants.ts b/utils/seed/mocks/restaurants.ts new file mode 100644 index 0000000..bf9ff21 --- /dev/null +++ b/utils/seed/mocks/restaurants.ts @@ -0,0 +1,87 @@ +import { faker } from "@faker-js/faker"; +import { schema } from "utils/seed/db"; +import { v4 as uuidv4 } from "uuid"; + +export default async function mockRestaurants(opts: { + ownerIds: string[]; + count: number; +}) { + const { count, ownerIds } = opts; + + return Array.from( + { length: count }, + () => + ({ + id: uuidv4(), + name: `${faker.company.name()}`, + legalEntity: `${faker.company.name()}, ${faker.string.numeric(8)}`, + address: `${faker.location.streetAddress()}`, + latitude: `${faker.location.latitude()}`, + longitude: `${faker.location.longitude()}`, + currency: "EUR", + timezone: "Europe/Tallinn", + isEnabled: true, + isClosedForever: false, + ...(ownerIds.length > 0 && { + ownerId: faker.helpers.arrayElement(ownerIds), + }), + }) satisfies typeof schema.restaurants.$inferInsert, + ); +} + +export function mockRestaurantPaymentMethods(opts: { + restaurantId: string; + count: number; +}) { + const { count, restaurantId } = opts; + + return Array.from( + { length: count }, + () => + ({ + id: uuidv4(), + icon: "CARD", + name: faker.commerce.department(), + type: "CUSTOM", + isActive: true, + restaurantId, + }) satisfies typeof schema.paymentMethods.$inferInsert, + ); +} + +export function mockRestaurantWorkshops(opts: { + restaurantId: string; + count: number; +}) { + const { count, restaurantId } = opts; + + return Array.from( + { length: count }, + () => + ({ + id: uuidv4(), + restaurantId, + name: faker.commerce.department(), + isEnabled: true, + isLabelPrintingEnabled: faker.datatype.boolean(), + }) satisfies typeof schema.restaurantWorkshops.$inferInsert, + ); +} + +export function mockRestaurantDishModifiers(opts: { + restaurantId: string; + count: number; +}) { + const { count, restaurantId } = opts; + + return Array.from( + { length: count }, + () => + ({ + id: uuidv4(), + restaurantId, + name: faker.commerce.department(), + isActive: true, + }) satisfies typeof schema.dishModifiers.$inferInsert, + ); +} diff --git a/utils/seed/mocks/workers.ts b/utils/seed/mocks/workers.ts new file mode 100644 index 0000000..1493e9c --- /dev/null +++ b/utils/seed/mocks/workers.ts @@ -0,0 +1,45 @@ +import { faker } from "@faker-js/faker"; +import argon2 from "argon2"; +import { schema } from "utils/seed/db"; +import { v4 as uuidv4 } from "uuid"; + +export type Worker = typeof schema.workers.$inferInsert; + +export default async function mockWorkers(opts: { + role?: Worker["role"]; + count: number; +}) { + const { count, role } = opts; + + const passwordHash = await argon2.hash("123456"); + + return Array.from({ length: count }, () => { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + + return { + id: uuidv4(), + login: `${faker.internet.userName({ + firstName, + lastName, + })}_${faker.number.int({ + min: 1, + max: 999, + })}`, + name: `${firstName} ${lastName}`, + passwordHash, + hiredAt: new Date(), + isBlocked: false, + onlineAt: new Date(), + role: faker.helpers.arrayElement([ + "KITCHENER", + "CASHIER", + "COURIER", + "DISPATCHER", + "WAITER", + "ADMIN", + ] as Worker["role"][]), + ...(role ? { role } : {}), + } satisfies Worker; + }); +} diff --git a/utils/seed/start-db.sh b/utils/seed/start-db.sh new file mode 100644 index 0000000..a206ae1 --- /dev/null +++ b/utils/seed/start-db.sh @@ -0,0 +1,48 @@ + #!/bin/bash + + # Load configuration from .env file + set -o allexport + source utils/seed/.env + set +o allexport + + # Container name + CONTAINER_NAME="${POSTGRES_CONTAINER_NAME:-postgres_db}" + + # Check if container exists and remove it + if docker ps -aq -f "name=${CONTAINER_NAME}" | grep -q .; then + echo "Stopping and removing existing container: ${CONTAINER_NAME}" + docker stop "${CONTAINER_NAME}" >/dev/null 2>&1 + docker rm "${CONTAINER_NAME}" >/dev/null 2>&1 + echo "Existing container removed." + else + echo "No existing container found with name: ${CONTAINER_NAME}" + fi + + # Run the new container + echo "Starting new container: ${CONTAINER_NAME}" + docker run -d \ + --name="${CONTAINER_NAME}" \ + -p "${POSTGRES_PORT:-5432}:5432" \ + -e POSTGRES_USER="${POSTGRES_USER:-postgres}" \ + -e POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-password}" \ + -e POSTGRES_DB="${POSTGRES_DB:-mydatabase}" \ + "${POSTGRES_IMAGE:-postgres:latest}" + + echo "Container ${CONTAINER_NAME} started in background. Waiting for it to be ready..." + + # Wait for the container to be ready + timeout 60 sh -c ' + until pg_isready -h localhost -p "${POSTGRES_PORT:-5432}" -U "${POSTGRES_USER:-postgres}"; do + echo "Waiting for PostgreSQL to be ready..." + sleep 2 + done + ' + + if [ $? -eq 0 ]; then + echo "PostgreSQL is ready to accept connections." + else + echo "Timed out waiting for PostgreSQL to be ready." + exit 1 + fi + + echo "Container ${CONTAINER_NAME} started successfully and is ready." diff --git a/yarn.lock b/yarn.lock index 10f2931..6b92989 100644 --- a/yarn.lock +++ b/yarn.lock @@ -50,6 +50,608 @@ ora "5.4.1" rxjs "7.8.1" +"@aws-crypto/crc32@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz#cfcc22570949c98c6689cfcbd2d693d36cdae2e1" + integrity sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg== + dependencies: + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + tslib "^2.6.2" + +"@aws-crypto/crc32c@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz#4e34aab7f419307821509a98b9b08e84e0c1917e" + integrity sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag== + dependencies: + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + tslib "^2.6.2" + +"@aws-crypto/sha1-browser@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz#b0ee2d2821d3861f017e965ef3b4cb38e3b6a0f4" + integrity sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg== + dependencies: + "@aws-crypto/supports-web-crypto" "^5.2.0" + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + "@aws-sdk/util-locate-window" "^3.0.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-crypto/sha256-browser@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz#153895ef1dba6f9fce38af550e0ef58988eb649e" + integrity sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw== + 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.222.0" + "@aws-sdk/util-locate-window" "^3.0.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-crypto/sha256-js@5.2.0", "@aws-crypto/sha256-js@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz#c4fdb773fdbed9a664fc1a95724e206cf3860042" + integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== + dependencies: + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + tslib "^2.6.2" + +"@aws-crypto/supports-web-crypto@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz#a1e399af29269be08e695109aa15da0a07b5b5fb" + integrity sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg== + dependencies: + tslib "^2.6.2" + +"@aws-crypto/util@5.2.0", "@aws-crypto/util@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-5.2.0.tgz#71284c9cffe7927ddadac793c14f14886d3876da" + integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== + dependencies: + "@aws-sdk/types" "^3.222.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-sdk/client-s3@^3.726.1": + version "3.726.1" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.726.1.tgz#05e9ae74be18758fc9d05a053777a8bb919fb24c" + integrity sha512-UpOGcob87DiuS2d3fW6vDZg94g57mNiOSkzvR/6GOdvBSlUgk8LLwVzGASB71FdKMl1EGEr4MeD5uKH9JsG+dw== + 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.726.0" + "@aws-sdk/client-sts" "3.726.1" + "@aws-sdk/core" "3.723.0" + "@aws-sdk/credential-provider-node" "3.726.0" + "@aws-sdk/middleware-bucket-endpoint" "3.726.0" + "@aws-sdk/middleware-expect-continue" "3.723.0" + "@aws-sdk/middleware-flexible-checksums" "3.723.0" + "@aws-sdk/middleware-host-header" "3.723.0" + "@aws-sdk/middleware-location-constraint" "3.723.0" + "@aws-sdk/middleware-logger" "3.723.0" + "@aws-sdk/middleware-recursion-detection" "3.723.0" + "@aws-sdk/middleware-sdk-s3" "3.723.0" + "@aws-sdk/middleware-ssec" "3.723.0" + "@aws-sdk/middleware-user-agent" "3.726.0" + "@aws-sdk/region-config-resolver" "3.723.0" + "@aws-sdk/signature-v4-multi-region" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@aws-sdk/util-endpoints" "3.726.0" + "@aws-sdk/util-user-agent-browser" "3.723.0" + "@aws-sdk/util-user-agent-node" "3.726.0" + "@aws-sdk/xml-builder" "3.723.0" + "@smithy/config-resolver" "^4.0.0" + "@smithy/core" "^3.0.0" + "@smithy/eventstream-serde-browser" "^4.0.0" + "@smithy/eventstream-serde-config-resolver" "^4.0.0" + "@smithy/eventstream-serde-node" "^4.0.0" + "@smithy/fetch-http-handler" "^5.0.0" + "@smithy/hash-blob-browser" "^4.0.0" + "@smithy/hash-node" "^4.0.0" + "@smithy/hash-stream-node" "^4.0.0" + "@smithy/invalid-dependency" "^4.0.0" + "@smithy/md5-js" "^4.0.0" + "@smithy/middleware-content-length" "^4.0.0" + "@smithy/middleware-endpoint" "^4.0.0" + "@smithy/middleware-retry" "^4.0.0" + "@smithy/middleware-serde" "^4.0.0" + "@smithy/middleware-stack" "^4.0.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/node-http-handler" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/smithy-client" "^4.0.0" + "@smithy/types" "^4.0.0" + "@smithy/url-parser" "^4.0.0" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-body-length-node" "^4.0.0" + "@smithy/util-defaults-mode-browser" "^4.0.0" + "@smithy/util-defaults-mode-node" "^4.0.0" + "@smithy/util-endpoints" "^3.0.0" + "@smithy/util-middleware" "^4.0.0" + "@smithy/util-retry" "^4.0.0" + "@smithy/util-stream" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + "@smithy/util-waiter" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/client-sso-oidc@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.726.0.tgz#6c83f6f95f15a7557f84c0d9ccd3f489368601a8" + integrity sha512-5JzTX9jwev7+y2Jkzjz0pd1wobB5JQfPOQF3N2DrJ5Pao0/k6uRYwE4NqB0p0HlGrMTDm7xNq7OSPPIPG575Jw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "3.723.0" + "@aws-sdk/credential-provider-node" "3.726.0" + "@aws-sdk/middleware-host-header" "3.723.0" + "@aws-sdk/middleware-logger" "3.723.0" + "@aws-sdk/middleware-recursion-detection" "3.723.0" + "@aws-sdk/middleware-user-agent" "3.726.0" + "@aws-sdk/region-config-resolver" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@aws-sdk/util-endpoints" "3.726.0" + "@aws-sdk/util-user-agent-browser" "3.723.0" + "@aws-sdk/util-user-agent-node" "3.726.0" + "@smithy/config-resolver" "^4.0.0" + "@smithy/core" "^3.0.0" + "@smithy/fetch-http-handler" "^5.0.0" + "@smithy/hash-node" "^4.0.0" + "@smithy/invalid-dependency" "^4.0.0" + "@smithy/middleware-content-length" "^4.0.0" + "@smithy/middleware-endpoint" "^4.0.0" + "@smithy/middleware-retry" "^4.0.0" + "@smithy/middleware-serde" "^4.0.0" + "@smithy/middleware-stack" "^4.0.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/node-http-handler" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/smithy-client" "^4.0.0" + "@smithy/types" "^4.0.0" + "@smithy/url-parser" "^4.0.0" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-body-length-node" "^4.0.0" + "@smithy/util-defaults-mode-browser" "^4.0.0" + "@smithy/util-defaults-mode-node" "^4.0.0" + "@smithy/util-endpoints" "^3.0.0" + "@smithy/util-middleware" "^4.0.0" + "@smithy/util-retry" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/client-sso@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.726.0.tgz#802a9a8d22db029361b859ae4a079ad680c98db4" + integrity sha512-NM5pjv2qglEc4XN3nnDqtqGsSGv1k5YTmzDo3W3pObItHmpS8grSeNfX9zSH+aVl0Q8hE4ZIgvTPNZ+GzwVlqg== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "3.723.0" + "@aws-sdk/middleware-host-header" "3.723.0" + "@aws-sdk/middleware-logger" "3.723.0" + "@aws-sdk/middleware-recursion-detection" "3.723.0" + "@aws-sdk/middleware-user-agent" "3.726.0" + "@aws-sdk/region-config-resolver" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@aws-sdk/util-endpoints" "3.726.0" + "@aws-sdk/util-user-agent-browser" "3.723.0" + "@aws-sdk/util-user-agent-node" "3.726.0" + "@smithy/config-resolver" "^4.0.0" + "@smithy/core" "^3.0.0" + "@smithy/fetch-http-handler" "^5.0.0" + "@smithy/hash-node" "^4.0.0" + "@smithy/invalid-dependency" "^4.0.0" + "@smithy/middleware-content-length" "^4.0.0" + "@smithy/middleware-endpoint" "^4.0.0" + "@smithy/middleware-retry" "^4.0.0" + "@smithy/middleware-serde" "^4.0.0" + "@smithy/middleware-stack" "^4.0.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/node-http-handler" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/smithy-client" "^4.0.0" + "@smithy/types" "^4.0.0" + "@smithy/url-parser" "^4.0.0" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-body-length-node" "^4.0.0" + "@smithy/util-defaults-mode-browser" "^4.0.0" + "@smithy/util-defaults-mode-node" "^4.0.0" + "@smithy/util-endpoints" "^3.0.0" + "@smithy/util-middleware" "^4.0.0" + "@smithy/util-retry" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/client-sts@3.726.1": + version "3.726.1" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz#49ab471db7e04792db24e181f8bb8c7787739b34" + integrity sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/client-sso-oidc" "3.726.0" + "@aws-sdk/core" "3.723.0" + "@aws-sdk/credential-provider-node" "3.726.0" + "@aws-sdk/middleware-host-header" "3.723.0" + "@aws-sdk/middleware-logger" "3.723.0" + "@aws-sdk/middleware-recursion-detection" "3.723.0" + "@aws-sdk/middleware-user-agent" "3.726.0" + "@aws-sdk/region-config-resolver" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@aws-sdk/util-endpoints" "3.726.0" + "@aws-sdk/util-user-agent-browser" "3.723.0" + "@aws-sdk/util-user-agent-node" "3.726.0" + "@smithy/config-resolver" "^4.0.0" + "@smithy/core" "^3.0.0" + "@smithy/fetch-http-handler" "^5.0.0" + "@smithy/hash-node" "^4.0.0" + "@smithy/invalid-dependency" "^4.0.0" + "@smithy/middleware-content-length" "^4.0.0" + "@smithy/middleware-endpoint" "^4.0.0" + "@smithy/middleware-retry" "^4.0.0" + "@smithy/middleware-serde" "^4.0.0" + "@smithy/middleware-stack" "^4.0.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/node-http-handler" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/smithy-client" "^4.0.0" + "@smithy/types" "^4.0.0" + "@smithy/url-parser" "^4.0.0" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-body-length-node" "^4.0.0" + "@smithy/util-defaults-mode-browser" "^4.0.0" + "@smithy/util-defaults-mode-node" "^4.0.0" + "@smithy/util-endpoints" "^3.0.0" + "@smithy/util-middleware" "^4.0.0" + "@smithy/util-retry" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/core@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.723.0.tgz#7a441b1362fa22609f80ede42d4e069829b9b4d1" + integrity sha512-UraXNmvqj3vScSsTkjMwQkhei30BhXlW5WxX6JacMKVtl95c7z0qOXquTWeTalYkFfulfdirUhvSZrl+hcyqTw== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/core" "^3.0.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/signature-v4" "^5.0.0" + "@smithy/smithy-client" "^4.0.0" + "@smithy/types" "^4.0.0" + "@smithy/util-middleware" "^4.0.0" + fast-xml-parser "4.4.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-env@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.723.0.tgz#7d85014d21ce50f9f6a108c5c673e87c54860eaa" + integrity sha512-OuH2yULYUHTVDUotBoP/9AEUIJPn81GQ/YBtZLoo2QyezRJ2QiO/1epVtbJlhNZRwXrToLEDmQGA2QfC8c7pbA== + dependencies: + "@aws-sdk/core" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-http@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.723.0.tgz#3b5db3225bb6dd97fecf22e18c06c3567eb1bce4" + integrity sha512-DTsKC6xo/kz/ZSs1IcdbQMTgiYbpGTGEd83kngFc1bzmw7AmK92DBZKNZpumf8R/UfSpTcj9zzUUmrWz1kD0eQ== + dependencies: + "@aws-sdk/core" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/fetch-http-handler" "^5.0.0" + "@smithy/node-http-handler" "^4.0.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/smithy-client" "^4.0.0" + "@smithy/types" "^4.0.0" + "@smithy/util-stream" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-ini@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.726.0.tgz#25115ecb3814f3f8e106cf12f5f34ab247095244" + integrity sha512-seTtcKL2+gZX6yK1QRPr5mDJIBOatrpoyrO8D5b8plYtV/PDbDW3mtDJSWFHet29G61ZmlNElyXRqQCXn9WX+A== + dependencies: + "@aws-sdk/core" "3.723.0" + "@aws-sdk/credential-provider-env" "3.723.0" + "@aws-sdk/credential-provider-http" "3.723.0" + "@aws-sdk/credential-provider-process" "3.723.0" + "@aws-sdk/credential-provider-sso" "3.726.0" + "@aws-sdk/credential-provider-web-identity" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/credential-provider-imds" "^4.0.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/shared-ini-file-loader" "^4.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-node@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.726.0.tgz#a997ea8e8e871e77cbebf6c8a6179d6f6af8897c" + integrity sha512-jjsewBcw/uLi24x8JbnuDjJad4VA9ROCE94uVRbEnGmUEsds75FWOKp3fWZLQlmjLtzsIbJOZLALkZP86liPaw== + dependencies: + "@aws-sdk/credential-provider-env" "3.723.0" + "@aws-sdk/credential-provider-http" "3.723.0" + "@aws-sdk/credential-provider-ini" "3.726.0" + "@aws-sdk/credential-provider-process" "3.723.0" + "@aws-sdk/credential-provider-sso" "3.726.0" + "@aws-sdk/credential-provider-web-identity" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/credential-provider-imds" "^4.0.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/shared-ini-file-loader" "^4.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-process@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.723.0.tgz#32bc55573b0a8f31e69b15939202d266adbbe711" + integrity sha512-fgupvUjz1+jeoCBA7GMv0L6xEk92IN6VdF4YcFhsgRHlHvNgm7ayaoKQg7pz2JAAhG/3jPX6fp0ASNy+xOhmPA== + dependencies: + "@aws-sdk/core" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/shared-ini-file-loader" "^4.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-sso@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.726.0.tgz#460dbc65e3d8dfd151d7b41e2da85ba7e7cc1f0a" + integrity sha512-WxkN76WeB08j2yw7jUH9yCMPxmT9eBFd9ZA/aACG7yzOIlsz7gvG3P2FQ0tVg25GHM0E4PdU3p/ByTOawzcOAg== + dependencies: + "@aws-sdk/client-sso" "3.726.0" + "@aws-sdk/core" "3.723.0" + "@aws-sdk/token-providers" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/shared-ini-file-loader" "^4.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-web-identity@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.723.0.tgz#5c17ea243b05b4dca0584db597ac68d8509dd754" + integrity sha512-tl7pojbFbr3qLcOE6xWaNCf1zEfZrIdSJtOPeSXfV/thFMMAvIjgf3YN6Zo1a6cxGee8zrV/C8PgOH33n+Ev/A== + dependencies: + "@aws-sdk/core" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-bucket-endpoint@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.726.0.tgz#9ba221dcc75f0415b2c854400477454aa87992d2" + integrity sha512-vpaP80rZqwu0C3ELayIcRIW84/nd1tadeoqllT+N9TDshuEvq4UJ+w47OBHB7RkHFJoc79lXXNYle0fdQdaE/A== + dependencies: + "@aws-sdk/types" "3.723.0" + "@aws-sdk/util-arn-parser" "3.723.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/types" "^4.0.0" + "@smithy/util-config-provider" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-expect-continue@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.723.0.tgz#59addac9b4cdc958ea1e06de9863db657e9c8e43" + integrity sha512-w/O0EkIzkiqvGu7U8Ke7tue0V0HYM5dZQrz6nVU+R8T2LddWJ+njEIHU4Wh8aHPLQXdZA5NQumv0xLPdEutykw== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-flexible-checksums@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.723.0.tgz#612ec13c4ba5dc906793172ece02a95059fffcc6" + integrity sha512-JY76mrUCLa0FHeMZp8X9+KK6uEuZaRZaQrlgq6zkXX/3udukH0T3YdFC+Y9uw5ddbiwZ5+KwgmlhnPpiXKfP4g== + dependencies: + "@aws-crypto/crc32" "5.2.0" + "@aws-crypto/crc32c" "5.2.0" + "@aws-crypto/util" "5.2.0" + "@aws-sdk/core" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/is-array-buffer" "^4.0.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/types" "^4.0.0" + "@smithy/util-middleware" "^4.0.0" + "@smithy/util-stream" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-host-header@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.723.0.tgz#f043689755e5b45ee6500b0d0a7090d9b4a864f7" + integrity sha512-LLVzLvk299pd7v4jN9yOSaWDZDfH0SnBPb6q+FDPaOCMGBY8kuwQso7e/ozIKSmZHRMGO3IZrflasHM+rI+2YQ== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-location-constraint@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.723.0.tgz#364e875a511d97431b6d337878c8a9bd5e2fdf64" + integrity sha512-inp9tyrdRWjGOMu1rzli8i2gTo0P4X6L7nNRXNTKfyPNZcBimZ4H0H1B671JofSI5isaklVy5r4pvv2VjjLSHw== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-logger@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.723.0.tgz#e8718056fc2d73a0d51308cad20676228be26652" + integrity sha512-chASQfDG5NJ8s5smydOEnNK7N0gDMyuPbx7dYYcm1t/PKtnVfvWF+DHCTrRC2Ej76gLJVCVizlAJKM8v8Kg3cg== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-recursion-detection@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.723.0.tgz#b4557c7f554492f56eeb0cbf5bc02dac7ef102a8" + integrity sha512-7usZMtoynT9/jxL/rkuDOFQ0C2mhXl4yCm67Rg7GNTstl67u7w5WN1aIRImMeztaKlw8ExjoTyo6WTs1Kceh7A== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-sdk-s3@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.723.0.tgz#d323c24b2268933bf51353d5215fa8baadaf8837" + integrity sha512-wfjOvNJVp8LDWhq4wO5jtSMb8Vgf4tNlR7QTEQfoYc6AGU3WlK5xyUQcpfcpwytEhQTN9u0cJLQpSyXDO+qSCw== + dependencies: + "@aws-sdk/core" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@aws-sdk/util-arn-parser" "3.723.0" + "@smithy/core" "^3.0.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/signature-v4" "^5.0.0" + "@smithy/smithy-client" "^4.0.0" + "@smithy/types" "^4.0.0" + "@smithy/util-config-provider" "^4.0.0" + "@smithy/util-middleware" "^4.0.0" + "@smithy/util-stream" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-ssec@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.723.0.tgz#b4adb65eb4ac029ee8b566f373b1d54aecbbd7ad" + integrity sha512-Bs+8RAeSMik6ZYCGSDJzJieGsDDh2fRbh1HQG94T8kpwBXVxMYihm6e9Xp2cyl+w9fyyCnh0IdCKChP/DvrdhA== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-user-agent@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.726.0.tgz#d8a791c61adca1f26332ce5128da7aa6c1433e89" + integrity sha512-hZvzuE5S0JmFie1r68K2wQvJbzyxJFdzltj9skgnnwdvLe8F/tz7MqLkm28uV0m4jeHk0LpiBo6eZaPkQiwsZQ== + dependencies: + "@aws-sdk/core" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@aws-sdk/util-endpoints" "3.726.0" + "@smithy/core" "^3.0.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/region-config-resolver@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.723.0.tgz#07b7ee4788ec7a7f5638bbbe0f9f7565125caf22" + integrity sha512-tGF/Cvch3uQjZIj34LY2mg8M2Dr4kYG8VU8Yd0dFnB1ybOEOveIK/9ypUo9ycZpB9oO6q01KRe5ijBaxNueUQg== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/types" "^4.0.0" + "@smithy/util-config-provider" "^4.0.0" + "@smithy/util-middleware" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.723.0.tgz#1de81c7ee98410dabbb22978bc5d4540c51a8afa" + integrity sha512-lJlVAa5Sl589qO8lwMLVUtnlF1Q7I+6k1Iomv2goY9d1bRl4q2N5Pit2qJVr2AMW0sceQXeh23i2a/CKOqVAdg== + dependencies: + "@aws-sdk/middleware-sdk-s3" "3.723.0" + "@aws-sdk/types" "3.723.0" + "@smithy/protocol-http" "^5.0.0" + "@smithy/signature-v4" "^5.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.723.0.tgz#ae173a18783886e592212abb820d28cbdb9d9237" + integrity sha512-hniWi1x4JHVwKElANh9afKIMUhAutHVBRD8zo6usr0PAoj+Waf220+1ULS74GXtLXAPCiNXl5Og+PHA7xT8ElQ== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/property-provider" "^4.0.0" + "@smithy/shared-ini-file-loader" "^4.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/types@3.723.0", "@aws-sdk/types@^3.222.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.723.0.tgz#f0c5a6024a73470421c469b6c1dd5bc4b8fb851b" + integrity sha512-LmK3kwiMZG1y5g3LGihT9mNkeNOmwEyPk6HGcJqh0wOSV4QpWoKu2epyKE4MLQNUUlz2kOVbVbOrwmI6ZcteuA== + dependencies: + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/util-arn-parser@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.723.0.tgz#e9bff2b13918a92d60e0012101dad60ed7db292c" + integrity sha512-ZhEfvUwNliOQROcAk34WJWVYTlTa4694kSVhDSjW6lE1bMataPnIN8A0ycukEzBXmd8ZSoBcQLn6lKGl7XIJ5w== + dependencies: + tslib "^2.6.2" + +"@aws-sdk/util-endpoints@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.726.0.tgz#0b39d4e2fe4b8b4a35d7e3714f1ed126114befd9" + integrity sha512-sLd30ASsPMoPn3XBK50oe/bkpJ4N8Bpb7SbhoxcY3Lk+fSASaWxbbXE81nbvCnkxrZCvkPOiDHzJCp1E2im71A== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/types" "^4.0.0" + "@smithy/util-endpoints" "^3.0.0" + tslib "^2.6.2" + +"@aws-sdk/util-locate-window@^3.0.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.723.0.tgz#174551bfdd2eb36d3c16e7023fd7e7ee96ad0fa9" + integrity sha512-Yf2CS10BqK688DRsrKI/EO6B8ff5J86NXe4C+VCysK7UOgN0l1zOTeTukZ3H8Q9tYYX3oaF1961o8vRkFm7Nmw== + dependencies: + tslib "^2.6.2" + +"@aws-sdk/util-user-agent-browser@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.723.0.tgz#64b0b4413c1be1585f95c3e2606429cc9f86df83" + integrity sha512-Wh9I6j2jLhNFq6fmXydIpqD1WyQLyTfSxjW9B+PXSnPyk3jtQW8AKQur7p97rO8LAUzVI0bv8kb3ZzDEVbquIg== + dependencies: + "@aws-sdk/types" "3.723.0" + "@smithy/types" "^4.0.0" + bowser "^2.11.0" + tslib "^2.6.2" + +"@aws-sdk/util-user-agent-node@3.726.0": + version "3.726.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.726.0.tgz#f093568a730b0d58ef7eca231f27309b11b8ef61" + integrity sha512-iEj6KX9o6IQf23oziorveRqyzyclWai95oZHDJtYav3fvLJKStwSjygO4xSF7ycHcTYeCHSLO1FFOHgGVs4Viw== + dependencies: + "@aws-sdk/middleware-user-agent" "3.726.0" + "@aws-sdk/types" "3.723.0" + "@smithy/node-config-provider" "^4.0.0" + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + +"@aws-sdk/xml-builder@3.723.0": + version "3.723.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.723.0.tgz#989580d65086985b82f05eaea0ee46d78a510398" + integrity sha512-5xK2SqGU1mzzsOeemy7cy3fGKxR1sEpUs4pEiIjaT0OIvU+fZaDVUEYWOqsgns6wI90XZEQJlXtI8uAHX/do5Q== + dependencies: + "@smithy/types" "^4.0.0" + tslib "^2.6.2" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244" @@ -349,12 +951,10 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@drizzle-team/studio@^0.0.39": - version "0.0.39" - resolved "https://registry.yarnpkg.com/@drizzle-team/studio/-/studio-0.0.39.tgz#70e9155503f7343e8f1917d316f93e64c23760e1" - integrity sha512-c5Hkm7MmQC2n5qAsKShjQrHoqlfGslB8+qWzsGGZ+2dHMRTNG60UuzalF0h0rvBax5uzPXuGkYLGaQ+TUX3yMw== - dependencies: - superjson "^2.2.1" +"@drizzle-team/brocli@^0.10.2": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@drizzle-team/brocli/-/brocli-0.10.2.tgz#9757c006a43daaa6f45512e6cf5fabed36fb9da7" + integrity sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w== "@esbuild-kit/core-utils@^3.3.2": version "3.3.2" @@ -634,6 +1234,123 @@ resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.3.1.tgz#7753df0cb88d7649becf984a96dd1bd0a26f43e3" integrity sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw== +"@fastify/accept-negotiator@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz#c1c66b3b771c09742a54dd5bc87c582f6b0630ff" + integrity sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ== + +"@fastify/ajv-compiler@^3.5.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz#907497a0e62a42b106ce16e279cf5788848e8e79" + integrity sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ== + dependencies: + ajv "^8.11.0" + ajv-formats "^2.1.1" + fast-uri "^2.0.0" + +"@fastify/busboy@^3.0.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-3.1.1.tgz#af3aea7f1e52ec916d8b5c9dcc0f09d4c060a3fc" + integrity sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw== + +"@fastify/cookie@9.4.0": + version "9.4.0" + resolved "https://registry.yarnpkg.com/@fastify/cookie/-/cookie-9.4.0.tgz#1be3bff03dbe746d3bbddbf31a37354076a190c1" + integrity sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg== + dependencies: + cookie-signature "^1.1.0" + fastify-plugin "^4.0.0" + +"@fastify/cors@9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@fastify/cors/-/cors-9.0.1.tgz#9ddb61b4a61e02749c5c54ca29f1c646794145be" + integrity sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q== + dependencies: + fastify-plugin "^4.0.0" + mnemonist "0.39.6" + +"@fastify/deepmerge@^2.0.0": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-2.0.2.tgz#5dcbda2acb266e309b8a1ca92fa48b2125e65fc0" + integrity sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q== + +"@fastify/error@^3.2.0", "@fastify/error@^3.3.0", "@fastify/error@^3.4.0": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@fastify/error/-/error-3.4.1.tgz#b14bb4cac3dd4ec614becbc643d1511331a6425c" + integrity sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ== + +"@fastify/error@^4.0.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@fastify/error/-/error-4.1.0.tgz#a6a3a8d2309bd8d3441512dff9a7c739d0c35fe2" + integrity sha512-KeFcciOr1eo/YvIXHP65S94jfEEqn1RxTRBT1aJaHxY5FK0/GDXYozsQMMWlZoHgi8i0s+YtrLsgj/JkUUjSkQ== + +"@fastify/fast-json-stringify-compiler@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz#5df89fa4d1592cbb8780f78998355feb471646d5" + integrity sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA== + dependencies: + fast-json-stringify "^5.7.0" + +"@fastify/formbody@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@fastify/formbody/-/formbody-7.4.0.tgz#5370b16d1ee58b9023008d1e883de60353a132ad" + integrity sha512-H3C6h1GN56/SMrZS8N2vCT2cZr7mIHzBHzOBa5OPpjfB/D6FzP9mMpE02ZzrFX0ANeh0BAJdoXKOF2e7IbV+Og== + dependencies: + fast-querystring "^1.0.0" + fastify-plugin "^4.0.0" + +"@fastify/merge-json-schemas@^0.1.0": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz#3551857b8a17a24e8c799e9f51795edb07baa0bc" + integrity sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA== + dependencies: + fast-deep-equal "^3.1.3" + +"@fastify/middie@8.3.3": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@fastify/middie/-/middie-8.3.3.tgz#1c1d2b21c41c56badb5c9aac6afe1e25d187b6a7" + integrity sha512-+WHavMQr9CNTZoy2cjoDxoWp76kZ3JKjAtZj5sXNlxX5XBzHig0TeCPfPc+1+NQmliXtndT3PFwAjrQHE/6wnQ== + dependencies: + "@fastify/error" "^3.2.0" + fastify-plugin "^4.0.0" + path-to-regexp "^6.3.0" + reusify "^1.0.4" + +"@fastify/multipart@8.3.1": + version "8.3.1" + resolved "https://registry.yarnpkg.com/@fastify/multipart/-/multipart-8.3.1.tgz#bd266584e026f812ab98f677e70171e0f1dd1812" + integrity sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ== + dependencies: + "@fastify/busboy" "^3.0.0" + "@fastify/deepmerge" "^2.0.0" + "@fastify/error" "^4.0.0" + fastify-plugin "^4.0.0" + secure-json-parse "^2.4.0" + stream-wormhole "^1.1.0" + +"@fastify/send@^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@fastify/send/-/send-2.1.0.tgz#1aa269ccb4b0940a2dadd1f844443b15d8224ea0" + integrity sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA== + dependencies: + "@lukeed/ms" "^2.0.1" + escape-html "~1.0.3" + fast-decode-uri-component "^1.0.1" + http-errors "2.0.0" + mime "^3.0.0" + +"@fastify/static@7.0.4": + version "7.0.4" + resolved "https://registry.yarnpkg.com/@fastify/static/-/static-7.0.4.tgz#51c6a58a5db60cf4724e88603c2ec38b9f53ab1b" + integrity sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q== + dependencies: + "@fastify/accept-negotiator" "^1.0.0" + "@fastify/send" "^2.0.0" + content-disposition "^0.5.3" + fastify-plugin "^4.0.0" + fastq "^1.17.0" + glob "^10.3.4" + "@humanwhocodes/config-array@^0.11.13": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" @@ -653,6 +1370,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw== +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -921,6 +1643,13 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@liaoliaots/nestjs-redis@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@liaoliaots/nestjs-redis/-/nestjs-redis-10.0.0.tgz#4afb60bc4659fe096c603fed11f7a087de1e4712" + integrity sha512-uCTmlzM4q+UYADwsJEQph0mbf4u0MrktFhByi50M5fNy/+fJoWlhSqrgvjtVKjHnqydxy1gyuU6vHJEOBp9cjg== + dependencies: + tslib "2.7.0" + "@ljharb/through@^2.3.9": version "2.3.11" resolved "https://registry.yarnpkg.com/@ljharb/through/-/through-2.3.11.tgz#783600ff12c06f21a76cc26e33abd0b1595092f9" @@ -933,33 +1662,175 @@ resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe" integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== -"@lukeed/ms@^2.0.2": +"@lukeed/ms@^2.0.1", "@lukeed/ms@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@lukeed/ms/-/ms-2.0.2.tgz#07f09e59a74c52f4d88c6db5c1054e819538e2a8" integrity sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA== -"@mapbox/node-pre-gyp@^1.0.11": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" - integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== - dependencies: - detect-libc "^2.0.0" - https-proxy-agent "^5.0.0" - make-dir "^3.1.0" - node-fetch "^2.6.7" - nopt "^5.0.0" - npmlog "^5.0.1" - rimraf "^3.0.2" - semver "^7.3.5" - tar "^6.1.11" - -"@mongodb-js/saslprep@^1.1.0": - version "1.1.4" - resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.1.4.tgz#24ec1c4915a65f5c506bb88c081731450d91bb1c" - integrity sha512-8zJ8N1x51xo9hwPh6AWnKdLGEC5N3lDa6kms1YHmFBoRhTpJR6HG8wWk0td1MVCu9cD4YBrvjZEtd5Obw0Fbnw== +"@mongodb-js/saslprep@^1.1.9": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.2.0.tgz#4373d7a87660ea44a0a7a461ff6d8bc832733a4b" + integrity sha512-+ywrb0AqkfaYuhHs6LxKWgqbh3I72EpEgESCw37o+9qPx9WTCkgDm2B+eMrwehGtHBWHFU4GXvnSCNiFhhausg== dependencies: sparse-bitfield "^3.0.3" +"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz#9edec61b22c3082018a79f6d1c30289ddf3d9d11" + integrity sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw== + +"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz#33677a275204898ad8acbf62734fc4dc0b6a4855" + integrity sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw== + +"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz#19edf7cdc2e7063ee328403c1d895a86dd28f4bb" + integrity sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg== + +"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz#94fb0543ba2e28766c3fc439cabbe0440ae70159" + integrity sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw== + +"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz#4a0609ab5fe44d07c9c60a11e4484d3c38bbd6e3" + integrity sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg== + +"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz#0aa5502d547b57abfc4ac492de68e2006e417242" + integrity sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ== + +"@napi-rs/nice-android-arm-eabi@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936" + integrity sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w== + +"@napi-rs/nice-android-arm64@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz#32fc32e9649bd759d2a39ad745e95766f6759d2f" + integrity sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA== + +"@napi-rs/nice-darwin-arm64@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz#d3c44c51b94b25a82d45803e2255891e833e787b" + integrity sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA== + +"@napi-rs/nice-darwin-x64@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz#f1b1365a8370c6a6957e90085a9b4873d0e6a957" + integrity sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ== + +"@napi-rs/nice-freebsd-x64@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz#4280f081efbe0b46c5165fdaea8b286e55a8f89e" + integrity sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw== + +"@napi-rs/nice-linux-arm-gnueabihf@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz#07aec23a9467ed35eb7602af5e63d42c5d7bd473" + integrity sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q== + +"@napi-rs/nice-linux-arm64-gnu@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz#038a77134cc6df3c48059d5a5e199d6f50fb9a90" + integrity sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA== + +"@napi-rs/nice-linux-arm64-musl@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz#715d0906582ba0cff025109f42e5b84ea68c2bcc" + integrity sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw== + +"@napi-rs/nice-linux-ppc64-gnu@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz#ac1c8f781c67b0559fa7a1cd4ae3ca2299dc3d06" + integrity sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q== + +"@napi-rs/nice-linux-riscv64-gnu@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz#b0a430549acfd3920ffd28ce544e2fe17833d263" + integrity sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig== + +"@napi-rs/nice-linux-s390x-gnu@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz#5b95caf411ad72a965885217db378c4d09733e97" + integrity sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg== + +"@napi-rs/nice-linux-x64-gnu@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz#a98cdef517549f8c17a83f0236a69418a90e77b7" + integrity sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA== + +"@napi-rs/nice-linux-x64-musl@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz#5e26843eafa940138aed437c870cca751c8a8957" + integrity sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ== + +"@napi-rs/nice-win32-arm64-msvc@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz#bd62617d02f04aa30ab1e9081363856715f84cd8" + integrity sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg== + +"@napi-rs/nice-win32-ia32-msvc@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz#b8b7aad552a24836027473d9b9f16edaeabecf18" + integrity sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw== + +"@napi-rs/nice-win32-x64-msvc@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz#37d8718b8f722f49067713e9f1e85540c9a3dd09" + integrity sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg== + +"@napi-rs/nice@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice/-/nice-1.0.1.tgz#483d3ff31e5661829a1efb4825591a135c3bfa7d" + integrity sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ== + optionalDependencies: + "@napi-rs/nice-android-arm-eabi" "1.0.1" + "@napi-rs/nice-android-arm64" "1.0.1" + "@napi-rs/nice-darwin-arm64" "1.0.1" + "@napi-rs/nice-darwin-x64" "1.0.1" + "@napi-rs/nice-freebsd-x64" "1.0.1" + "@napi-rs/nice-linux-arm-gnueabihf" "1.0.1" + "@napi-rs/nice-linux-arm64-gnu" "1.0.1" + "@napi-rs/nice-linux-arm64-musl" "1.0.1" + "@napi-rs/nice-linux-ppc64-gnu" "1.0.1" + "@napi-rs/nice-linux-riscv64-gnu" "1.0.1" + "@napi-rs/nice-linux-s390x-gnu" "1.0.1" + "@napi-rs/nice-linux-x64-gnu" "1.0.1" + "@napi-rs/nice-linux-x64-musl" "1.0.1" + "@napi-rs/nice-win32-arm64-msvc" "1.0.1" + "@napi-rs/nice-win32-ia32-msvc" "1.0.1" + "@napi-rs/nice-win32-x64-msvc" "1.0.1" + +"@nest-zod/z@*": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@nest-zod/z/-/z-1.0.1.tgz#80b228ef7797ea38da7770b272ee319465363021" + integrity sha512-NsJ2GN7/k92/UCKfJ/y6R+ANNee44gt8t3U84zqGTcHDlz4gvxWnpciVkAlN7vJOfFOLTjPAXpuZMnpmPNA1Iw== + +"@nestjs/axios@^3.1.3": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@nestjs/axios/-/axios-3.1.3.tgz#cf73f317f89800ec2f6f04b577677617c5aef0d9" + integrity sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ== + +"@nestjs/bull-shared@^11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@nestjs/bull-shared/-/bull-shared-11.0.2.tgz#dd12c404c68f130b4d1c2486c533d4c6e58b9b17" + integrity sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA== + dependencies: + tslib "2.8.1" + +"@nestjs/bullmq@^11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@nestjs/bullmq/-/bullmq-11.0.2.tgz#a72225dda7eb303db9637f0e02ce2494f9f16eef" + integrity sha512-Lq6lGpKkETsm0RDcUktlzsthFoE3A5QTMp2FwPi1eztKqKD6/90KS1TcnC9CJFzjpUaYnQzIMrlNs55e+/wsHA== + dependencies: + "@nestjs/bull-shared" "^11.0.2" + tslib "2.8.1" + "@nestjs/cli@^10.0.0": version "10.3.0" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-10.3.0.tgz#5f9ef49a60baf4b39cb87e4b74240f7c9339e923" @@ -988,14 +1859,14 @@ webpack "5.89.0" webpack-node-externals "3.0.0" -"@nestjs/common@^10.0.0": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.3.0.tgz#d78f0ff2062d1d53c79c170a79c12a1548e2e598" - integrity sha512-DGv34UHsZBxCM3H5QGE2XE/+oLJzz5+714JQjBhjD9VccFlQs3LRxo/epso4l7nJIiNlZkPyIUC8WzfU/5RTsQ== +"@nestjs/common@10.4.15": + version "10.4.15" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" + integrity sha512-vaLg1ZgwhG29BuLDxPA9OAcIlgqzp9/N8iG0wGapyUNTf4IY4O6zAHgN6QalwLhFxq7nOI021vdRojR1oF3bqg== dependencies: uid "2.0.2" iterare "1.2.1" - tslib "2.6.2" + tslib "2.8.1" "@nestjs/config@^3.1.1": version "3.1.1" @@ -1007,17 +1878,17 @@ lodash "4.17.21" uuid "9.0.0" -"@nestjs/core@^10.0.0": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.3.0.tgz#d5c6b26d6d9280664910d5481153d25c5da4ec00" - integrity sha512-N06P5ncknW/Pm8bj964WvLIZn2gNhHliCBoAO1LeBvNImYkecqKcrmLbY49Fa1rmMfEM3MuBHeDys3edeuYAOA== +"@nestjs/core@10.4.15": + version "10.4.15" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.4.15.tgz#1343a3395d5c54e9b792608cb75eef39053806d5" + integrity sha512-UBejmdiYwaH6fTsz2QFBlC1cJHM+3UDeLZN+CiP9I1fRv2KlBZsmozGLbV5eS1JAVWJB4T5N5yQ0gjN8ZvcS2w== dependencies: uid "2.0.2" "@nuxtjs/opencollective" "0.3.2" fast-safe-stringify "2.1.1" iterare "1.2.1" - path-to-regexp "3.2.0" - tslib "2.6.2" + path-to-regexp "3.3.0" + tslib "2.8.1" "@nestjs/jwt@^10.2.0": version "10.2.0" @@ -1042,116 +1913,758 @@ resolved "https://registry.yarnpkg.com/@nestjs/passport/-/passport-10.0.3.tgz#26ec5b2167d364e04962c115fcef80d10e185367" integrity sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ== -"@nestjs/platform-express@^10.0.0": +"@nestjs/platform-fastify@10.4.15": + version "10.4.15" + resolved "https://registry.yarnpkg.com/@nestjs/platform-fastify/-/platform-fastify-10.4.15.tgz#743bf2b6f38f2315e9ebddcc9a4ffe63059035ca" + integrity sha512-h1raWeoQEI3Q1wLOLDTbwpObFBMbdq22DlJNyTNY1pyNDyc5K99mnhFnk+eAiM/Di+pjewv5Nh2CzPUZIguA/g== + dependencies: + "@fastify/cors" "9.0.1" + "@fastify/formbody" "7.4.0" + "@fastify/middie" "8.3.3" + fastify "4.28.1" + light-my-request "6.3.0" + path-to-regexp "3.3.0" + tslib "2.8.1" + +"@nestjs/platform-socket.io@^11.0.8": + version "11.0.8" + resolved "https://registry.yarnpkg.com/@nestjs/platform-socket.io/-/platform-socket.io-11.0.8.tgz#ea928234efd3524b741ecfbd7e951bbf2c45f612" + integrity sha512-DUpfRSDgxu+z9czB6ddFdQFawSAIr7jEbNOvpjpjYErvDitUdos57FhTw9IJxIm2EAOHoiCk4g3tN59GfjdwfQ== + dependencies: + socket.io "4.8.1" + tslib "2.8.1" + +"@nestjs/schematics@^10.0.0", "@nestjs/schematics@^10.0.1": + version "10.1.0" + resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.1.0.tgz#bf9be846bafad0f45f0ea5ed80aaaf971bb90873" + integrity sha512-HQWvD3F7O0Sv3qHS2jineWxPLmBTLlyjT6VdSw2EAIXulitmV+ErxB3TCVQQORlNkl5p5cwRYWyBaOblDbNFIQ== + dependencies: + "@angular-devkit/core" "17.0.9" + "@angular-devkit/schematics" "17.0.9" + comment-json "4.2.3" + jsonc-parser "3.2.0" + pluralize "8.0.0" + +"@nestjs/swagger@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-7.2.0.tgz#a449ab0a2319eca8e695cf3892f4e63bbd8a916d" + integrity sha512-W7WPq561/79w27ZEgViXS7c5hqPwT7QXhsLsSeu2jeBROUhMM825QKDFKbMmtb643IW5dznJ4PjherlZZgtMvg== + dependencies: + "@nestjs/mapped-types" "2.0.4" + js-yaml "4.1.0" + lodash "4.17.21" + path-to-regexp "3.2.0" + swagger-ui-dist "5.11.0" + +"@nestjs/testing@^10.0.0": version "10.3.0" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.3.0.tgz#ea69b048ef90b78b1001eb1c6b02d9d798f5f3af" - integrity sha512-E4hUW48bYv8OHbP9XQg6deefmXb0pDSSuE38SdhA0mJ37zGY7C5EqqBUdlQk4ttfD+OdnbIgJ1zOokT6dd2d7A== + resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.3.0.tgz#a4de362de88f855ddee5ed6f5cc25bd6aaf4c4c3" + integrity sha512-8DM+bw1qASCvaEnoHUQhypCOf54+G5R21MeFBMvnSk5DtKaWVZuzDP2GjLeYCpTH19WeP6LrrjHv3rX2LKU02A== dependencies: - body-parser "1.20.2" - cors "2.8.5" - express "4.18.2" - multer "1.4.4-lts.1" tslib "2.6.2" -"@nestjs/schematics@^10.0.0", "@nestjs/schematics@^10.0.1": - version "10.1.0" - resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.1.0.tgz#bf9be846bafad0f45f0ea5ed80aaaf971bb90873" - integrity sha512-HQWvD3F7O0Sv3qHS2jineWxPLmBTLlyjT6VdSw2EAIXulitmV+ErxB3TCVQQORlNkl5p5cwRYWyBaOblDbNFIQ== +"@nestjs/throttler@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@nestjs/throttler/-/throttler-5.1.1.tgz#cefdf1bc28c6692769412692db78753317f9e59b" + integrity sha512-0fJAGroqpQLnQlERslx2fG264YCXU35nMfiFhykY6/chgc56/W0QPM6BEEf9Q/Uca9lXh5IyjE0fqFToksbP/A== + dependencies: + md5 "^2.2.1" + +"@nestjs/websockets@^11.0.8": + version "11.0.8" + resolved "https://registry.yarnpkg.com/@nestjs/websockets/-/websockets-11.0.8.tgz#a799b783e189bc2c9db709afd1daa6b27feb71fd" + integrity sha512-wyS512+QWhWhE8NU1DgbAPkCaaOSNK1xBIgRlgpYg5/tKuhu4lc5r8iMdZAQn6xay++ELlOqsSlyuj0J2BixOA== + dependencies: + iterare "1.2.1" + object-hash "3.0.0" + tslib "2.8.1" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@nuxtjs/opencollective@0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz#620ce1044f7ac77185e825e1936115bb38e2681c" + integrity sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA== + dependencies: + chalk "^4.1.0" + consola "^2.15.0" + node-fetch "^2.6.1" + +"@phc/format@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@phc/format/-/format-1.0.0.tgz#b5627003b3216dc4362125b13f48a4daa76680e4" + integrity sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@pkgr/core@^0.1.0": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" + integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== + +"@sec-ant/readable-stream@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" + integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== + +"@sesamecare-oss/redlock@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@sesamecare-oss/redlock/-/redlock-1.4.0.tgz#f312a396f73e7e1ef389c50377d8d43ecadb1f01" + integrity sha512-2z589R+yxKLN4CgKxP1oN4dsg6Y548SE4bVYam/R0kHk7Q9VrQ9l66q+k1ehhSLLY4or9hcchuF9/MhuuZdjJg== + +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + +"@sindresorhus/is@^5.2.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-5.6.0.tgz#41dd6093d34652cddb5d5bdeee04eafc33826668" + integrity sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g== + +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^10.0.2": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@smithy/abort-controller@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-4.0.1.tgz#7c5e73690c4105ad264c2896bd1ea822450c3819" + integrity sha512-fiUIYgIgRjMWznk6iLJz35K2YxSLHzLBA/RC6lBrKfQ8fHbPfvk7Pk9UvpKoHgJjI18MnbPuEju53zcVy6KF1g== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/chunked-blob-reader-native@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz#33cbba6deb8a3c516f98444f65061784f7cd7f8c" + integrity sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig== + dependencies: + "@smithy/util-base64" "^4.0.0" + tslib "^2.6.2" + +"@smithy/chunked-blob-reader@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz#3f6ea5ff4e2b2eacf74cefd737aa0ba869b2e0f6" + integrity sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw== + dependencies: + tslib "^2.6.2" + +"@smithy/config-resolver@^4.0.0", "@smithy/config-resolver@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.0.1.tgz#3d6c78bbc51adf99c9819bb3f0ea197fe03ad363" + integrity sha512-Igfg8lKu3dRVkTSEm98QpZUvKEOa71jDX4vKRcvJVyRc3UgN3j7vFMf0s7xLQhYmKa8kyJGQgUJDOV5V3neVlQ== + dependencies: + "@smithy/node-config-provider" "^4.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-config-provider" "^4.0.0" + "@smithy/util-middleware" "^4.0.1" + tslib "^2.6.2" + +"@smithy/core@^3.0.0", "@smithy/core@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.1.0.tgz#7af3f2f06ffd84e98e402da21dd9a40c2abb58ff" + integrity sha512-swFv0wQiK7TGHeuAp6lfF5Kw1dHWsTrCuc+yh4Kh05gEShjsE2RUxHucEerR9ih9JITNtaHcSpUThn5Y/vDw0A== + dependencies: + "@smithy/middleware-serde" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-stream" "^4.0.1" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^4.0.0", "@smithy/credential-provider-imds@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.1.tgz#807110739982acd1588a4847b61e6edf196d004e" + integrity sha512-l/qdInaDq1Zpznpmev/+52QomsJNZ3JkTl5yrTl02V6NBgJOQ4LY0SFw/8zsMwj3tLe8vqiIuwF6nxaEwgf6mg== + dependencies: + "@smithy/node-config-provider" "^4.0.1" + "@smithy/property-provider" "^4.0.1" + "@smithy/types" "^4.1.0" + "@smithy/url-parser" "^4.0.1" + tslib "^2.6.2" + +"@smithy/eventstream-codec@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-4.0.1.tgz#8e0beae84013eb3b497dd189470a44bac4411bae" + integrity sha512-Q2bCAAR6zXNVtJgifsU16ZjKGqdw/DyecKNgIgi7dlqw04fqDu0mnq+JmGphqheypVc64CYq3azSuCpAdFk2+A== + dependencies: + "@aws-crypto/crc32" "5.2.0" + "@smithy/types" "^4.1.0" + "@smithy/util-hex-encoding" "^4.0.0" + tslib "^2.6.2" + +"@smithy/eventstream-serde-browser@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.1.tgz#cdbbb18b9371da363eff312d78a10f6bad82df28" + integrity sha512-HbIybmz5rhNg+zxKiyVAnvdM3vkzjE6ccrJ620iPL8IXcJEntd3hnBl+ktMwIy12Te/kyrSbUb8UCdnUT4QEdA== + dependencies: + "@smithy/eventstream-serde-universal" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/eventstream-serde-config-resolver@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.0.1.tgz#3662587f507ad7fac5bd4505c4ed6ed0ac49a010" + integrity sha512-lSipaiq3rmHguHa3QFF4YcCM3VJOrY9oq2sow3qlhFY+nBSTF/nrO82MUQRPrxHQXA58J5G1UnU2WuJfi465BA== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/eventstream-serde-node@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.1.tgz#3799c33e0148d2b923a66577d1dbc590865742ce" + integrity sha512-o4CoOI6oYGYJ4zXo34U8X9szDe3oGjmHgsMGiZM0j4vtNoT+h80TLnkUcrLZR3+E6HIxqW+G+9WHAVfl0GXK0Q== + dependencies: + "@smithy/eventstream-serde-universal" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/eventstream-serde-universal@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.1.tgz#ddb2ab9f62b8ab60f50acd5f7c8b3ac9d27468e2" + integrity sha512-Z94uZp0tGJuxds3iEAZBqGU2QiaBHP4YytLUjwZWx+oUeohCsLyUm33yp4MMBmhkuPqSbQCXq5hDet6JGUgHWA== + dependencies: + "@smithy/eventstream-codec" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/fetch-http-handler@^5.0.0", "@smithy/fetch-http-handler@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.0.1.tgz#8463393442ca6a1644204849e42c386066f0df79" + integrity sha512-3aS+fP28urrMW2KTjb6z9iFow6jO8n3MFfineGbndvzGZit3taZhKWtTorf+Gp5RpFDDafeHlhfsGlDCXvUnJA== + dependencies: + "@smithy/protocol-http" "^5.0.1" + "@smithy/querystring-builder" "^4.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-base64" "^4.0.0" + tslib "^2.6.2" + +"@smithy/hash-blob-browser@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.1.tgz#cda18d5828e8724d97441ea9cc4fd16d0db9da39" + integrity sha512-rkFIrQOKZGS6i1D3gKJ8skJ0RlXqDvb1IyAphksaFOMzkn3v3I1eJ8m7OkLj0jf1McP63rcCEoLlkAn/HjcTRw== + dependencies: + "@smithy/chunked-blob-reader" "^5.0.0" + "@smithy/chunked-blob-reader-native" "^4.0.0" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/hash-node@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.0.1.tgz#ce78fc11b848a4f47c2e1e7a07fb6b982d2f130c" + integrity sha512-TJ6oZS+3r2Xu4emVse1YPB3Dq3d8RkZDKcPr71Nj/lJsdAP1c7oFzYqEn1IBc915TsgLl2xIJNuxCz+gLbLE0w== + dependencies: + "@smithy/types" "^4.1.0" + "@smithy/util-buffer-from" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@smithy/hash-stream-node@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.0.1.tgz#06126859a3cb1a11e50b61c5a097a4d9a5af2ac1" + integrity sha512-U1rAE1fxmReCIr6D2o/4ROqAQX+GffZpyMt3d7njtGDr2pUNmAKRWa49gsNVhCh2vVAuf3wXzWwNr2YN8PAXIw== + dependencies: + "@smithy/types" "^4.1.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@smithy/invalid-dependency@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.0.1.tgz#704d1acb6fac105558c17d53f6d55da6b0d6b6fc" + integrity sha512-gdudFPf4QRQ5pzj7HEnu6FhKRi61BfH/Gk5Yf6O0KiSbr1LlVhgjThcvjdu658VE6Nve8vaIWB8/fodmS1rBPQ== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/is-array-buffer@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz#f84f0d9f9a36601a9ca9381688bd1b726fd39111" + integrity sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA== + dependencies: + tslib "^2.6.2" + +"@smithy/is-array-buffer@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz#55a939029321fec462bcc574890075cd63e94206" + integrity sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw== + dependencies: + tslib "^2.6.2" + +"@smithy/md5-js@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.0.1.tgz#d7622e94dc38ecf290876fcef04369217ada8f07" + integrity sha512-HLZ647L27APi6zXkZlzSFZIjpo8po45YiyjMGJZM3gyDY8n7dPGdmxIIljLm4gPt/7rRvutLTTkYJpZVfG5r+A== + dependencies: + "@smithy/types" "^4.1.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@smithy/middleware-content-length@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.0.1.tgz#378bc94ae623f45e412fb4f164b5bb90b9de2ba3" + integrity sha512-OGXo7w5EkB5pPiac7KNzVtfCW2vKBTZNuCctn++TTSOMpe6RZO/n6WEC1AxJINn3+vWLKW49uad3lo/u0WJ9oQ== + dependencies: + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/middleware-endpoint@^4.0.0", "@smithy/middleware-endpoint@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.0.1.tgz#a80ee5b7d2ba3f735e7cc77864f8211db1c63ccb" + integrity sha512-hCCOPu9+sRI7Wj0rZKKnGylKXBEd9cQJetzjQqe8cT4PWvtQAbvNVa6cgAONiZg9m8LaXtP9/waxm3C3eO4hiw== + dependencies: + "@smithy/core" "^3.1.0" + "@smithy/middleware-serde" "^4.0.1" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + "@smithy/url-parser" "^4.0.1" + "@smithy/util-middleware" "^4.0.1" + tslib "^2.6.2" + +"@smithy/middleware-retry@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.0.1.tgz#1f7fb3086f80d49a5990ffeafade0a264d230146" + integrity sha512-n3g2zZFgOWaz2ZYCy8+4wxSmq+HSTD8QKkRhFDv+nkxY1o7gzyp4PDz/+tOdcNPMPZ/A6Mt4aVECYNjQNiaHJw== + dependencies: + "@smithy/node-config-provider" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/service-error-classification" "^4.0.1" + "@smithy/smithy-client" "^4.1.0" + "@smithy/types" "^4.1.0" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-retry" "^4.0.1" + tslib "^2.6.2" + uuid "^9.0.1" + +"@smithy/middleware-serde@^4.0.0", "@smithy/middleware-serde@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.0.1.tgz#4c9218cecd5316ab696e73fdc1c80b38bcaffa99" + integrity sha512-Fh0E2SOF+S+P1+CsgKyiBInAt3o2b6Qk7YOp2W0Qx2XnfTdfMuSDKUEcnrtpxCzgKJnqXeLUZYqtThaP0VGqtA== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/middleware-stack@^4.0.0", "@smithy/middleware-stack@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.0.1.tgz#c157653f9df07f7c26e32f49994d368e4e071d22" + integrity sha512-dHwDmrtR/ln8UTHpaIavRSzeIk5+YZTBtLnKwDW3G2t6nAupCiQUvNzNoHBpik63fwUaJPtlnMzXbQrNFWssIA== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/node-config-provider@^4.0.0", "@smithy/node-config-provider@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.0.1.tgz#4e84fe665c0774d5f4ebb75144994fc6ebedf86e" + integrity sha512-8mRTjvCtVET8+rxvmzRNRR0hH2JjV0DFOmwXPrISmTIJEfnCBugpYYGAsCj8t41qd+RB5gbheSQ/6aKZCQvFLQ== + dependencies: + "@smithy/property-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/node-http-handler@^4.0.0", "@smithy/node-http-handler@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.0.1.tgz#3673102f9d719ccbbe18183f59cee368b3881b2c" + integrity sha512-ddQc7tvXiVLC5c3QKraGWde761KSk+mboCheZoWtuqnXh5l0WKyFy3NfDIM/dsKrI9HlLVH/21pi9wWK2gUFFA== + dependencies: + "@smithy/abort-controller" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/querystring-builder" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/property-provider@^4.0.0", "@smithy/property-provider@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.0.1.tgz#8d35d5997af2a17cf15c5e921201ef6c5e3fc870" + integrity sha512-o+VRiwC2cgmk/WFV0jaETGOtX16VNPp2bSQEzu0whbReqE1BMqsP2ami2Vi3cbGVdKu1kq9gQkDAGKbt0WOHAQ== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/protocol-http@^5.0.0", "@smithy/protocol-http@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.0.1.tgz#37c248117b29c057a9adfad4eb1d822a67079ff1" + integrity sha512-TE4cpj49jJNB/oHyh/cRVEgNZaoPaxd4vteJNB0yGidOCVR0jCw/hjPVsT8Q8FRmj8Bd3bFZt8Dh7xGCT+xMBQ== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/querystring-builder@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.0.1.tgz#37e1e05d0d33c6f694088abc3e04eafb65cb6976" + integrity sha512-wU87iWZoCbcqrwszsOewEIuq+SU2mSoBE2CcsLwE0I19m0B2gOJr1MVjxWcDQYOzHbR1xCk7AcOBbGFUYOKvdg== + dependencies: + "@smithy/types" "^4.1.0" + "@smithy/util-uri-escape" "^4.0.0" + tslib "^2.6.2" + +"@smithy/querystring-parser@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.0.1.tgz#312dc62b146f8bb8a67558d82d4722bb9211af42" + integrity sha512-Ma2XC7VS9aV77+clSFylVUnPZRindhB7BbmYiNOdr+CHt/kZNJoPP0cd3QxCnCFyPXC4eybmyE98phEHkqZ5Jw== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/service-error-classification@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.0.1.tgz#84e78579af46c7b79c900b6d6cc822c9465f3259" + integrity sha512-3JNjBfOWpj/mYfjXJHB4Txc/7E4LVq32bwzE7m28GN79+M1f76XHflUaSUkhOriprPDzev9cX/M+dEB80DNDKA== + dependencies: + "@smithy/types" "^4.1.0" + +"@smithy/shared-ini-file-loader@^4.0.0", "@smithy/shared-ini-file-loader@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.1.tgz#d35c21c29454ca4e58914a4afdde68d3b2def1ee" + integrity sha512-hC8F6qTBbuHRI/uqDgqqi6J0R4GtEZcgrZPhFQnMhfJs3MnUTGSnR1NSJCJs5VWlMydu0kJz15M640fJlRsIOw== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/signature-v4@^5.0.0": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.0.1.tgz#f93401b176150286ba246681031b0503ec359270" + integrity sha512-nCe6fQ+ppm1bQuw5iKoeJ0MJfz2os7Ic3GBjOkLOPtavbD1ONoyE3ygjBfz2ythFWm4YnRm6OxW+8p/m9uCoIA== + dependencies: + "@smithy/is-array-buffer" "^4.0.0" + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-hex-encoding" "^4.0.0" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-uri-escape" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@smithy/smithy-client@^4.0.0", "@smithy/smithy-client@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.1.0.tgz#066ddfb5214a75e619e43c657dcfe531fd757d43" + integrity sha512-NiboZnrsrZY+Cy5hQNbYi+nVNssXVi2I+yL4CIKNIanOhH8kpC5PKQ2jx/MQpwVr21a3XcVoQBArlpRF36OeEQ== + dependencies: + "@smithy/core" "^3.1.0" + "@smithy/middleware-endpoint" "^4.0.1" + "@smithy/middleware-stack" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-stream" "^4.0.1" + tslib "^2.6.2" + +"@smithy/types@^4.0.0", "@smithy/types@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.1.0.tgz#19de0b6087bccdd4182a334eb5d3d2629699370f" + integrity sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw== + dependencies: + tslib "^2.6.2" + +"@smithy/url-parser@^4.0.0", "@smithy/url-parser@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.0.1.tgz#b47743f785f5b8d81324878cbb1b5f834bf8d85a" + integrity sha512-gPXcIEUtw7VlK8f/QcruNXm7q+T5hhvGu9tl63LsJPZ27exB6dtNwvh2HIi0v7JcXJ5emBxB+CJxwaLEdJfA+g== + dependencies: + "@smithy/querystring-parser" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/util-base64@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-4.0.0.tgz#8345f1b837e5f636e5f8470c4d1706ae0c6d0358" + integrity sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg== + dependencies: + "@smithy/util-buffer-from" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + +"@smithy/util-body-length-browser@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz#965d19109a4b1e5fe7a43f813522cce718036ded" + integrity sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA== + dependencies: + tslib "^2.6.2" + +"@smithy/util-body-length-node@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz#3db245f6844a9b1e218e30c93305bfe2ffa473b3" + integrity sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg== + dependencies: + tslib "^2.6.2" + +"@smithy/util-buffer-from@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz#6fc88585165ec73f8681d426d96de5d402021e4b" + integrity sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA== + dependencies: + "@smithy/is-array-buffer" "^2.2.0" + tslib "^2.6.2" + +"@smithy/util-buffer-from@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz#b23b7deb4f3923e84ef50c8b2c5863d0dbf6c0b9" + integrity sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug== + dependencies: + "@smithy/is-array-buffer" "^4.0.0" + tslib "^2.6.2" + +"@smithy/util-config-provider@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz#e0c7c8124c7fba0b696f78f0bd0ccb060997d45e" + integrity sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w== + dependencies: + tslib "^2.6.2" + +"@smithy/util-defaults-mode-browser@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.1.tgz#ace4442dbc73a144e686097a2855c3dfa9d8fb2f" + integrity sha512-nkQifWzWUHw/D0aLPgyKut+QnJ5X+5E8wBvGfvrYLLZ86xPfVO6MoqfQo/9s4bF3Xscefua1M6KLZtobHMWrBg== dependencies: - "@angular-devkit/core" "17.0.9" - "@angular-devkit/schematics" "17.0.9" - comment-json "4.2.3" - jsonc-parser "3.2.0" - pluralize "8.0.0" + "@smithy/property-provider" "^4.0.1" + "@smithy/smithy-client" "^4.1.0" + "@smithy/types" "^4.1.0" + bowser "^2.11.0" + tslib "^2.6.2" -"@nestjs/swagger@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-7.2.0.tgz#a449ab0a2319eca8e695cf3892f4e63bbd8a916d" - integrity sha512-W7WPq561/79w27ZEgViXS7c5hqPwT7QXhsLsSeu2jeBROUhMM825QKDFKbMmtb643IW5dznJ4PjherlZZgtMvg== +"@smithy/util-defaults-mode-node@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.1.tgz#c18f0014852b947aa54013e437da13a10a04c8e6" + integrity sha512-LeAx2faB83litC9vaOdwFaldtto2gczUHxfFf8yoRwDU3cwL4/pDm7i0hxsuBCRk5mzHsrVGw+3EVCj32UZMdw== + dependencies: + "@smithy/config-resolver" "^4.0.1" + "@smithy/credential-provider-imds" "^4.0.1" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/property-provider" "^4.0.1" + "@smithy/smithy-client" "^4.1.0" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@smithy/util-endpoints@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.0.1.tgz#44ccbf1721447966f69496c9003b87daa8f61975" + integrity sha512-zVdUENQpdtn9jbpD9SCFK4+aSiavRb9BxEtw9ZGUR1TYo6bBHbIoi7VkrFQ0/RwZlzx0wRBaRmPclj8iAoJCLA== dependencies: - "@nestjs/mapped-types" "2.0.4" - js-yaml "4.1.0" - lodash "4.17.21" - path-to-regexp "3.2.0" - swagger-ui-dist "5.11.0" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" -"@nestjs/testing@^10.0.0": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.3.0.tgz#a4de362de88f855ddee5ed6f5cc25bd6aaf4c4c3" - integrity sha512-8DM+bw1qASCvaEnoHUQhypCOf54+G5R21MeFBMvnSk5DtKaWVZuzDP2GjLeYCpTH19WeP6LrrjHv3rX2LKU02A== +"@smithy/util-hex-encoding@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz#dd449a6452cffb37c5b1807ec2525bb4be551e8d" + integrity sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw== dependencies: - tslib "2.6.2" + tslib "^2.6.2" -"@nestjs/throttler@^5.1.1": - version "5.1.1" - resolved "https://registry.yarnpkg.com/@nestjs/throttler/-/throttler-5.1.1.tgz#cefdf1bc28c6692769412692db78753317f9e59b" - integrity sha512-0fJAGroqpQLnQlERslx2fG264YCXU35nMfiFhykY6/chgc56/W0QPM6BEEf9Q/Uca9lXh5IyjE0fqFToksbP/A== +"@smithy/util-middleware@^4.0.0", "@smithy/util-middleware@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.0.1.tgz#58d363dcd661219298c89fa176a28e98ccc4bf43" + integrity sha512-HiLAvlcqhbzhuiOa0Lyct5IIlyIz0PQO5dnMlmQ/ubYM46dPInB+3yQGkfxsk6Q24Y0n3/JmcA1v5iEhmOF5mA== dependencies: - md5 "^2.2.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== +"@smithy/util-retry@^4.0.0", "@smithy/util-retry@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.0.1.tgz#fb5f26492383dcb9a09cc4aee23a10f839cd0769" + integrity sha512-WmRHqNVwn3kI3rKk1LsKcVgPBG6iLTBGC1iYOV3GQegwJ3E8yjzHytPt26VNzOWr1qu0xE03nK0Ug8S7T7oufw== dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" + "@smithy/service-error-classification" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== +"@smithy/util-stream@^4.0.0", "@smithy/util-stream@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.0.1.tgz#cbbaf4a73ca5a6292074cd83682c0c401321e863" + integrity sha512-Js16gOgU6Qht6qTPfuJgb+1YD4AEO+5Y1UPGWKSp3BNo8ONl/qhXSYDhFKJtwybRJynlCqvP5IeiaBsUmkSPTQ== + dependencies: + "@smithy/fetch-http-handler" "^5.0.1" + "@smithy/node-http-handler" "^4.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-buffer-from" "^4.0.0" + "@smithy/util-hex-encoding" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== +"@smithy/util-uri-escape@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz#a96c160c76f3552458a44d8081fade519d214737" + integrity sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg== dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" + tslib "^2.6.2" -"@nuxtjs/opencollective@0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz#620ce1044f7ac77185e825e1936115bb38e2681c" - integrity sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA== +"@smithy/util-utf8@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz#dd96d7640363259924a214313c3cf16e7dd329c5" + integrity sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A== dependencies: - chalk "^4.1.0" - consola "^2.15.0" - node-fetch "^2.6.1" + "@smithy/util-buffer-from" "^2.2.0" + tslib "^2.6.2" -"@phc/format@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@phc/format/-/format-1.0.0.tgz#b5627003b3216dc4362125b13f48a4daa76680e4" - integrity sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== +"@smithy/util-utf8@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-4.0.0.tgz#09ca2d9965e5849e72e347c130f2a29d5c0c863c" + integrity sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow== + dependencies: + "@smithy/util-buffer-from" "^4.0.0" + tslib "^2.6.2" -"@pkgjs/parseargs@^0.11.0": - version "0.11.0" - resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" - integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@smithy/util-waiter@^4.0.0": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.0.2.tgz#0a73a0fcd30ea7bbc3009cf98ad199f51b8eac51" + integrity sha512-piUTHyp2Axx3p/kc2CIJkYSv0BAaheBQmbACZgQSSfWUumWNW+R1lL+H9PDBxKJkvOeEX+hKYEFiwO8xagL8AQ== + dependencies: + "@smithy/abort-controller" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" -"@pkgr/core@^0.1.0": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" - integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== +"@supercharge/request-ip@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@supercharge/request-ip/-/request-ip-1.2.0.tgz#b8a8164322e09de3fa9b6f556885795c4841a7d4" + integrity sha512-wlt6JW69MHqLY2M6Sm/jVyCojNRKq2CBvwH0Hbx24SFhDQQGkgEjeKxVutDxHSyrWixFaOSLXC27euzxijhyMQ== + +"@swc/cli@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@swc/cli/-/cli-0.6.0.tgz#fe986a436797c9d3850938366dbd660c9ba1101f" + integrity sha512-Q5FsI3Cw0fGMXhmsg7c08i4EmXCrcl+WnAxb6LYOLHw4JFFC3yzmx9LaXZ7QMbA+JZXbigU2TirI7RAfO0Qlnw== + dependencies: + "@swc/counter" "^0.1.3" + "@xhmikosr/bin-wrapper" "^13.0.5" + commander "^8.3.0" + fast-glob "^3.2.5" + minimatch "^9.0.3" + piscina "^4.3.1" + semver "^7.3.8" + slash "3.0.0" + source-map "^0.7.3" + +"@swc/core-darwin-arm64@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.13.tgz#ebd30fa0cda7ad28fc471cd756402ff17801cfb4" + integrity sha512-loSERhLaQ9XDS+5Kdx8cLe2tM1G0HLit8MfehipAcsdctpo79zrRlkW34elOf3tQoVPKUItV0b/rTuhjj8NtHg== + +"@swc/core-darwin-x64@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.11.13.tgz#9cad870d48ebff805e8946ddcbe3d8312182f70b" + integrity sha512-uSA4UwgsDCIysUPfPS8OrQTH2h9spO7IYFd+1NB6dJlVGUuR6jLKuMBOP1IeLeax4cGHayvkcwSJ3OvxHwgcZQ== + +"@swc/core-linux-arm-gnueabihf@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.13.tgz#51839e5a850bfa300e2c838fee8379e4dba1de78" + integrity sha512-boVtyJzS8g30iQfe8Q46W5QE/cmhKRln/7NMz/5sBP/am2Lce9NL0d05NnFwEWJp1e2AMGHFOdRr3Xg1cDiPKw== + +"@swc/core-linux-arm64-gnu@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.13.tgz#4145f1e504bdfa92604aee883d777bc8c4fba5d7" + integrity sha512-+IK0jZ84zHUaKtwpV+T+wT0qIUBnK9v2xXD03vARubKF+eUqCsIvcVHXmLpFuap62dClMrhCiwW10X3RbXNlHw== + +"@swc/core-linux-arm64-musl@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.13.tgz#b1813ae2e99e386ca16fff5af6601ac45ef57c5b" + integrity sha512-+ukuB8RHD5BHPCUjQwuLP98z+VRfu+NkKQVBcLJGgp0/+w7y0IkaxLY/aKmrAS5ofCNEGqKL+AOVyRpX1aw+XA== + +"@swc/core-linux-x64-gnu@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.13.tgz#13b89a0194c4033c01400e9c65d9c21c56a4a6cd" + integrity sha512-q9H3WI3U3dfJ34tdv60zc8oTuWvSd5fOxytyAO9Pc5M82Hic3jjWaf2xBekUg07ubnMZpyfnv+MlD+EbUI3Llw== + +"@swc/core-linux-x64-musl@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.13.tgz#0d0e5aa889dd4da69723e2287c3c1714d9bfd8aa" + integrity sha512-9aaZnnq2pLdTbAzTSzy/q8dr7Woy3aYIcQISmw1+Q2/xHJg5y80ZzbWSWKYca/hKonDMjIbGR6dp299I5J0aeA== + +"@swc/core-win32-arm64-msvc@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.13.tgz#ad7281f9467e3de09f52615afe2276a8ef738a9d" + integrity sha512-n3QZmDewkHANcoHvtwvA6yJbmS4XJf0MBMmwLZoKDZ2dOnC9D/jHiXw7JOohEuzYcpLoL5tgbqmjxa3XNo9Oow== + +"@swc/core-win32-ia32-msvc@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.13.tgz#046f6dbddb5b69a29bbaa98de104090a46088b74" + integrity sha512-wM+Nt4lc6YSJFthCx3W2dz0EwFNf++j0/2TQ0Js9QLJuIxUQAgukhNDVCDdq8TNcT0zuA399ALYbvj5lfIqG6g== + +"@swc/core-win32-x64-msvc@1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.13.tgz#0412620d8594a7d3e482d3e79d9e89d80f9a14c0" + integrity sha512-+X5/uW3s1L5gK7wAo0E27YaAoidJDo51dnfKSfU7gF3mlEUuWH8H1bAy5OTt2mU4eXtfsdUMEVXSwhDlLtQkuA== + +"@swc/core@^1.11.13": + version "1.11.13" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.11.13.tgz#54ca047c7139d35e358a16a5afcbf8aec0d6e8b6" + integrity sha512-9BXdYz12Wl0zWmZ80PvtjBWeg2ncwJ9L5WJzjhN6yUTZWEV/AwAdVdJnIEp4pro3WyKmAaMxcVOSbhuuOZco5g== + dependencies: + "@swc/counter" "^0.1.3" + "@swc/types" "^0.1.19" + optionalDependencies: + "@swc/core-darwin-arm64" "1.11.13" + "@swc/core-darwin-x64" "1.11.13" + "@swc/core-linux-arm-gnueabihf" "1.11.13" + "@swc/core-linux-arm64-gnu" "1.11.13" + "@swc/core-linux-arm64-musl" "1.11.13" + "@swc/core-linux-x64-gnu" "1.11.13" + "@swc/core-linux-x64-musl" "1.11.13" + "@swc/core-win32-arm64-msvc" "1.11.13" + "@swc/core-win32-ia32-msvc" "1.11.13" + "@swc/core-win32-x64-msvc" "1.11.13" + +"@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@sinonjs/commons@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" - integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== +"@swc/types@^0.1.19": + version "0.1.20" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.20.tgz#6fcc39b6fd958f5b976031f693eb85be8c16d15a" + integrity sha512-/rlIpxwKrhz4BIplXf6nsEHtqlhzuNN34/k3kMAXH4/lvVoA3cdq+60aqVNnyvw2uITEaCi0WV3pxBe4dQqoXQ== dependencies: - type-detect "4.0.8" + "@swc/counter" "^0.1.3" -"@sinonjs/fake-timers@^10.0.2": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" - integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== +"@szmarczak/http-timer@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-5.0.1.tgz#c7c1bf1141cdd4751b0399c8fc7b8b664cd5be3a" + integrity sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw== dependencies: - "@sinonjs/commons" "^3.0.0" + defer-to-connect "^2.0.1" + +"@tokenizer/token@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" + integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== "@tsconfig/node10@^1.0.7": version "1.0.9" @@ -1221,18 +2734,18 @@ dependencies: "@types/node" "*" -"@types/cookie-parser@^1.4.6": - version "1.4.6" - resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.6.tgz#002643c514cccf883a65cbe044dbdc38c0b92ade" - integrity sha512-KoooCrD56qlLskXPLGUiJxOMnv5l/8m7cQD2OxJ73NPMhuSz9PmvwRD6EpjDyKBVrdJDdQ4bQK7JFNHnNmax0w== - dependencies: - "@types/express" "*" - "@types/cookiejar@^2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" integrity sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== +"@types/cors@^2.8.12": + version "2.8.17" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" + integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA== + dependencies: + "@types/node" "*" + "@types/eslint-scope@^3.7.3": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" @@ -1281,6 +2794,11 @@ dependencies: "@types/node" "*" +"@types/http-cache-semantics@^4.0.2": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4" + integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA== + "@types/http-errors@*": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" @@ -1345,6 +2863,13 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== +"@types/multer@^1.4.12": + version "1.4.12" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.12.tgz#da67bd0c809f3a63fe097c458c0d4af1fea50ab7" + integrity sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg== + dependencies: + "@types/express" "*" + "@types/node@*", "@types/node@^20.3.1": version "20.11.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.5.tgz#be10c622ca7fcaa3cf226cf80166abc31389d86e" @@ -1352,6 +2877,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@>=10.0.0": + version "22.13.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.1.tgz#a2a3fefbdeb7ba6b89f40371842162fac0934f33" + integrity sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew== + dependencies: + undici-types "~6.20.0" + "@types/passport-jwt@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/passport-jwt/-/passport-jwt-4.0.0.tgz#96fd75557b83352efe9f5ca55d5e7ab9e7194d4d" @@ -1375,6 +2907,15 @@ dependencies: "@types/express" "*" +"@types/pg@^8.11.10": + version "8.11.10" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.11.10.tgz#b8fb2b2b759d452fe3ec182beadd382563b63291" + integrity sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^4.0.1" + "@types/qs@*": version "6.9.11" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.11.tgz#208d8a30bc507bd82e03ada29e4732ea46a6bbda" @@ -1429,11 +2970,21 @@ "@types/methods" "^1.1.4" "@types/superagent" "^8.1.0" +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/validator@^13.11.8": version "13.11.8" resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.11.8.tgz#bb1162ec0fe6f87c95ca812f15b996fcc5e1e2dc" integrity sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ== +"@types/validator@^13.12.2": + version "13.12.2" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5" + integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA== + "@types/webidl-conversions@*": version "7.0.3" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" @@ -1549,6 +3100,11 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@vvo/tzdb@^6.157.0": + version "6.157.0" + resolved "https://registry.yarnpkg.com/@vvo/tzdb/-/tzdb-6.157.0.tgz#a74b8f2fa3312cb21020cf777a441e753cc11be6" + integrity sha512-NB1SgzdVqdYvH3ExzY2fkcGMfAa7Zqrn1t2f+o4A8M4h2Mj6WiyQADPgSIdwR8crh+E6Mh/7nMvyIeZIs3OQJA== + "@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" @@ -1670,6 +3226,104 @@ "@webassemblyjs/ast" "1.11.6" "@xtuc/long" "4.2.2" +"@xhmikosr/archive-type@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@xhmikosr/archive-type/-/archive-type-7.0.0.tgz#74746a210b59d7d8a77aa69a422f0dae025b3798" + integrity sha512-sIm84ZneCOJuiy3PpWR5bxkx3HaNt1pqaN+vncUBZIlPZCq8ASZH+hBVdu5H8znR7qYC6sKwx+ie2Q7qztJTxA== + dependencies: + file-type "^19.0.0" + +"@xhmikosr/bin-check@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@xhmikosr/bin-check/-/bin-check-7.0.3.tgz#9ce53f339db419f08e799f4c55b82b38ede13c95" + integrity sha512-4UnCLCs8DB+itHJVkqFp9Zjg+w/205/J2j2wNBsCEAm/BuBmtua2hhUOdAMQE47b1c7P9Xmddj0p+X1XVsfHsA== + dependencies: + execa "^5.1.1" + isexe "^2.0.0" + +"@xhmikosr/bin-wrapper@^13.0.5": + version "13.0.5" + resolved "https://registry.yarnpkg.com/@xhmikosr/bin-wrapper/-/bin-wrapper-13.0.5.tgz#2f5804ac0a3331df11d76d08dab3a3eb674ef0df" + integrity sha512-DT2SAuHDeOw0G5bs7wZbQTbf4hd8pJ14tO0i4cWhRkIJfgRdKmMfkDilpaJ8uZyPA0NVRwasCNAmMJcWA67osw== + dependencies: + "@xhmikosr/bin-check" "^7.0.3" + "@xhmikosr/downloader" "^15.0.1" + "@xhmikosr/os-filter-obj" "^3.0.0" + bin-version-check "^5.1.0" + +"@xhmikosr/decompress-tar@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@xhmikosr/decompress-tar/-/decompress-tar-8.0.1.tgz#ca9cc65453b5ac59bb5eb897b6f1390a4905b565" + integrity sha512-dpEgs0cQKJ2xpIaGSO0hrzz3Kt8TQHYdizHsgDtLorWajuHJqxzot9Hbi0huRxJuAGG2qiHSQkwyvHHQtlE+fg== + dependencies: + file-type "^19.0.0" + is-stream "^2.0.1" + tar-stream "^3.1.7" + +"@xhmikosr/decompress-tarbz2@^8.0.1": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@xhmikosr/decompress-tarbz2/-/decompress-tarbz2-8.0.2.tgz#1c19b4a59585321a7c64ab0ff1f85f92b66fca1a" + integrity sha512-p5A2r/AVynTQSsF34Pig6olt9CvRj6J5ikIhzUd3b57pUXyFDGtmBstcw+xXza0QFUh93zJsmY3zGeNDlR2AQQ== + dependencies: + "@xhmikosr/decompress-tar" "^8.0.1" + file-type "^19.6.0" + is-stream "^2.0.1" + seek-bzip "^2.0.0" + unbzip2-stream "^1.4.3" + +"@xhmikosr/decompress-targz@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@xhmikosr/decompress-targz/-/decompress-targz-8.0.1.tgz#54dbd48e83861db43857970c2fcdbd431371e95b" + integrity sha512-mvy5AIDIZjQ2IagMI/wvauEiSNHhu/g65qpdM4EVoYHUJBAmkQWqcPJa8Xzi1aKVTmOA5xLJeDk7dqSjlHq8Mg== + dependencies: + "@xhmikosr/decompress-tar" "^8.0.1" + file-type "^19.0.0" + is-stream "^2.0.1" + +"@xhmikosr/decompress-unzip@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@xhmikosr/decompress-unzip/-/decompress-unzip-7.0.0.tgz#dcf9417829bf9fe474f6064513017949915e14c0" + integrity sha512-GQMpzIpWTsNr6UZbISawsGI0hJ4KA/mz5nFq+cEoPs12UybAqZWKbyIaZZyLbJebKl5FkLpsGBkrplJdjvUoSQ== + dependencies: + file-type "^19.0.0" + get-stream "^6.0.1" + yauzl "^3.1.2" + +"@xhmikosr/decompress@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@xhmikosr/decompress/-/decompress-10.0.1.tgz#63650498b4f3dd0fb5ee645dc5a35e1a7baad632" + integrity sha512-6uHnEEt5jv9ro0CDzqWlFgPycdE+H+kbJnwyxgZregIMLQ7unQSCNVsYG255FoqU8cP46DyggI7F7LohzEl8Ag== + dependencies: + "@xhmikosr/decompress-tar" "^8.0.1" + "@xhmikosr/decompress-tarbz2" "^8.0.1" + "@xhmikosr/decompress-targz" "^8.0.1" + "@xhmikosr/decompress-unzip" "^7.0.0" + graceful-fs "^4.2.11" + make-dir "^4.0.0" + strip-dirs "^3.0.0" + +"@xhmikosr/downloader@^15.0.1": + version "15.0.1" + resolved "https://registry.yarnpkg.com/@xhmikosr/downloader/-/downloader-15.0.1.tgz#5dd34cf8aa8ce5f1e156e03188f7ba65abfa45c6" + integrity sha512-fiuFHf3Dt6pkX8HQrVBsK0uXtkgkVlhrZEh8b7VgoDqFf+zrgFBPyrwCqE/3nDwn3hLeNz+BsrS7q3mu13Lp1g== + dependencies: + "@xhmikosr/archive-type" "^7.0.0" + "@xhmikosr/decompress" "^10.0.1" + content-disposition "^0.5.4" + defaults "^3.0.0" + ext-name "^5.0.0" + file-type "^19.0.0" + filenamify "^6.0.0" + get-stream "^6.0.1" + got "^13.0.0" + +"@xhmikosr/os-filter-obj@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@xhmikosr/os-filter-obj/-/os-filter-obj-3.0.0.tgz#917d380868d03ce853f90a919716ef73f6b26808" + integrity sha512-siPY6BD5dQ2SZPl3I0OZBHL27ZqZvLEosObsZRQ1NUB8qcxegwt0T9eKtV96JMFQpIz1elhkzqOg4c/Ri6Dp9A== + dependencies: + arch "^3.0.0" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -1680,12 +3334,17 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abstract-logging@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839" + integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== -accepts@~1.3.8: +accept-language-parser@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/accept-language-parser/-/accept-language-parser-1.5.0.tgz#8877c54040a8dcb59e0a07d9c1fde42298334791" + integrity sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw== + +accepts@~1.3.4: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== @@ -1713,20 +3372,20 @@ acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -ajv-formats@2.1.1: +ajv-formats@2.1.1, ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== dependencies: ajv "^8.0.0" +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" @@ -1752,6 +3411,16 @@ ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.10.0, ajv@^8.11.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-colors@4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -1811,32 +3480,24 @@ append-field@^1.0.0: resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== -"aproba@^1.0.3 || ^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" - integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== - -are-we-there-yet@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" - integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== - dependencies: - delegates "^1.0.0" - readable-stream "^3.6.0" +arch@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/arch/-/arch-3.0.0.tgz#a44e7077da4615fc5f1e3da21fbfc201d2c1817c" + integrity sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q== arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== -argon2@^0.31.2: - version "0.31.2" - resolved "https://registry.yarnpkg.com/argon2/-/argon2-0.31.2.tgz#bb6590a63b8ff71c4a3a1907572893ff8dd9ffff" - integrity sha512-QSnJ8By5Mth60IEte45w9Y7v6bWcQw3YhRtJKKN8oNCxnTLDiv/AXXkDPf2srTMfxFVn3QJdVv2nhXESsUa+Yg== +argon2@^0.41.1: + version "0.41.1" + resolved "https://registry.yarnpkg.com/argon2/-/argon2-0.41.1.tgz#30ce6b013e273bc7e92c558d40e66d35e5e8c63b" + integrity sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ== dependencies: - "@mapbox/node-pre-gyp" "^1.0.11" "@phc/format" "^1.0.0" - node-addon-api "^7.0.0" + node-addon-api "^8.1.0" + node-gyp-build "^4.8.1" argparse@^1.0.7: version "1.0.10" @@ -1850,11 +3511,6 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -arr-union@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" - integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q== - array-buffer-byte-length@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" @@ -1863,11 +3519,6 @@ array-buffer-byte-length@^1.0.1: call-bind "^1.0.5" is-array-buffer "^3.0.4" -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== - array-includes@^3.1.7: version "3.1.7" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.7.tgz#8cd2e01b26f7a3086cbc87271593fe921c62abda" @@ -1955,6 +3606,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + available-typed-arrays@^1.0.6, available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -1962,6 +3618,28 @@ available-typed-arrays@^1.0.6, available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +avvio@^8.3.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/avvio/-/avvio-8.4.0.tgz#7cbd5bca74f0c9effa944ced601f94ffd8afc5ed" + integrity sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA== + dependencies: + "@fastify/error" "^3.3.0" + fastq "^1.17.1" + +axios@^1.8.4: + version "1.8.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.4.tgz#78990bb4bc63d2cae072952d374835950a82f447" + integrity sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +b4a@^1.6.4: + version "1.6.7" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.7.tgz#a99587d4ebbfbd5a6e3b21bdb5d5fa385767abe4" + integrity sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg== + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -2027,11 +3705,38 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bare-events@^2.2.0: + version "2.5.4" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.5.4.tgz#16143d435e1ed9eafd1ab85f12b89b3357a41745" + integrity sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +base64id@2.0.0, base64id@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + +bin-version-check@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/bin-version-check/-/bin-version-check-5.1.0.tgz#788e80e036a87313f8be7908bc20e5abe43f0837" + integrity sha512-bYsvMqJ8yNGILLz1KP9zKLzQ6YpljV3ln1gqhuLkUtyfGi3qXKGuK2p+U4NAvjVFzDFiBBtOpCOSFNuYYEGZ5g== + dependencies: + bin-version "^6.0.0" + semver "^7.5.3" + semver-truncate "^3.0.0" + +bin-version@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/bin-version/-/bin-version-6.0.0.tgz#08ecbe5fc87898b441425e145f9e105064d00315" + integrity sha512-nk5wEsP4RiKjG+vF+uG8lFsEn4d7Y6FVDamzzftSunXOoOcOOkzcWdKVlGgFFwlUQCj63SgnUkLLGF8v7lufhw== + dependencies: + execa "^5.0.0" + find-versions "^5.0.0" + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2046,41 +3751,10 @@ bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" -body-parser@1.20.1: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== - dependencies: - bytes "3.1.2" - content-type "~1.0.4" - 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.11.0" - raw-body "2.5.1" - type-is "~1.6.18" - unpipe "1.0.0" - -body-parser@1.20.2: - version "1.20.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" - integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== - 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.11.0" - raw-body "2.5.2" - type-is "~1.6.18" - unpipe "1.0.0" +bowser@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" + integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== brace-expansion@^1.1.7: version "1.1.11" @@ -2104,6 +3778,13 @@ braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + browserslist@^4.14.5, browserslist@^4.22.2: version "4.22.2" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.2.tgz#704c4943072bd81ea18997f3bd2180e89c77874b" @@ -2128,10 +3809,15 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -bson@^6.2.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/bson/-/bson-6.3.0.tgz#d47acba525ba7d7eb0e816c10538bce26a337fe0" - integrity sha512-balJfqwwTBddxfnidJZagCBPP/f48zj9Sdp3OJswREOgsJzHiQSaOIAtApSgDQFYgHqAvFkp53AFSqjMDZoTFw== +bson@^6.10.3: + version "6.10.3" + resolved "https://registry.yarnpkg.com/bson/-/bson-6.10.3.tgz#5f9a463af6b83e264bedd08b236d1356a30eda47" + integrity sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ== + +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== buffer-equal-constant-time@1.0.1: version "1.0.1" @@ -2143,12 +3829,7 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer-writer@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" - integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== - -buffer@^5.5.0: +buffer@^5.2.1, buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -2156,17 +3837,43 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" -busboy@^1.0.0: +bullmq@^5.40.0: + version "5.40.0" + resolved "https://registry.yarnpkg.com/bullmq/-/bullmq-5.40.0.tgz#bcbf1d2d579fee2a9aa5154e747ad60c86de7afe" + integrity sha512-tmrk32EmcbtUOGPSdwlDUcc0w+nAMqCisk8vEFFmG8aOzIehz0BxTNSj6Grh0qoMugRF3VglWk8HGUBnWqU2Fw== + dependencies: + cron-parser "^4.9.0" + ioredis "^5.4.1" + msgpackr "^1.11.2" + node-abort-controller "^3.1.1" + semver "^7.5.4" + tslib "^2.0.0" + uuid "^9.0.0" + +busboy@^1.0.0, busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== dependencies: streamsearch "^1.1.0" -bytes@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== +cacheable-lookup@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz#3476a8215d046e5a3202a9209dd13fec1f933a27" + integrity sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w== + +cacheable-request@^10.2.8: + version "10.2.14" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-10.2.14.tgz#eb915b665fda41b79652782df3f553449c406b9d" + integrity sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ== + dependencies: + "@types/http-cache-semantics" "^4.0.2" + get-stream "^6.0.1" + http-cache-semantics "^4.1.1" + keyv "^4.5.3" + mimic-response "^4.0.0" + normalize-url "^8.0.0" + responselike "^3.0.0" call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.5" @@ -2203,11 +3910,6 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -camelcase@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-7.0.1.tgz#f02e50af9fd7782bc8b88a3558c32fd3a388f048" - integrity sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw== - caniuse-lite@^1.0.30001565: version "1.0.30001579" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz#45c065216110f46d6274311a4b3fcf6278e0852a" @@ -2230,7 +3932,7 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^5.2.0, chalk@^5.3.0: +chalk@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== @@ -2265,10 +3967,20 @@ chokidar@3.5.3, chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" -chownr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" - integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== +chokidar@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" chrome-trace-event@^1.0.2: version "1.0.3" @@ -2299,17 +4011,6 @@ class-validator@^0.14.1: libphonenumber-js "^1.10.53" validator "^13.9.0" -cli-color@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.3.tgz#73769ba969080629670f3f2ef69a4bf4e7cc1879" - integrity sha512-OkoZnxyC4ERN3zLzZaY9Emb7f/MhBOIpePv0Ycok0fJYT+Ouo00UBEIwsVsr0yoow++n5YWlSUgST9GKhNHiRQ== - dependencies: - d "^1.0.1" - es5-ext "^0.10.61" - es6-iterator "^2.0.3" - memoizee "^0.4.15" - timers-ext "^0.1.7" - cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -2350,22 +4051,16 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" -clone-deep@^0.2.4: - version "0.2.4" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.2.4.tgz#4e73dd09e9fb971cc38670c5dced9c1896481cc6" - integrity sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg== - dependencies: - for-own "^0.1.3" - is-plain-object "^2.0.1" - kind-of "^3.0.2" - lazy-cache "^1.0.3" - shallow-clone "^0.1.2" - clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== +cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -2400,11 +4095,6 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-support@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" - integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -2422,10 +4112,15 @@ commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^9.4.1: - version "9.5.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30" - integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== +commander@^6.0.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + +commander@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== comment-json@4.2.3: version "4.2.3" @@ -2458,74 +4153,59 @@ concat-stream@^1.5.2: readable-stream "^2.2.2" typedarray "^0.0.6" +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + consola@^2.15.0: version "2.15.3" resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw== -console-control-strings@^1.0.0, console-control-strings@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== - -content-disposition@0.5.4: +content-disposition@^0.5.3, content-disposition@^0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" - integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== - -cookie-parser@^1.4.6: - version "1.4.6" - resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.6.tgz#3ac3a7d35a7a03bbc7e365073a26074824214594" - integrity sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA== - dependencies: - cookie "0.4.1" - cookie-signature "1.0.6" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== +cookie-signature@^1.1.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== -cookie@0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" - integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== +cookie@^0.7.0, cookie@~0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@^1.0.1, cookie@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.0.2.tgz#27360701532116bd3f1f9416929d176afe1e4610" + integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA== cookiejar@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== -copy-anything@^3.0.2: - version "3.0.5" - resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" - integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== - dependencies: - is-what "^4.1.8" - core-util-is@^1.0.3, core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cors@2.8.5: +cors@~2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -2561,6 +4241,13 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron-parser@^4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== + dependencies: + luxon "^3.2.1" + cross-env@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -2582,22 +4269,24 @@ crypt@0.0.2: resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow== -d@1, d@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" - integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== +csrf-csrf@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/csrf-csrf/-/csrf-csrf-3.1.0.tgz#d2388ded379bff967a39b4d16824c180c6f9d97e" + integrity sha512-kZacFfFbdYFxNnFdigRHCzVAq019vJyUUtgPLjCtzh6jMXcWmf8bGUx/hsqtSEMXaNcPm8iXpjC+hW5aeOsRMg== dependencies: - es5-ext "^0.10.50" - type "^1.0.1" + http-errors "^2.0.0" -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" +date-fns-tz@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz#647dc56d38ac33a3e37b65e9d5c4cda5af5e58e6" + integrity sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ== + +date-fns@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== -debug@4, debug@4.x, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: +debug@4.x, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2611,6 +4300,20 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + dedent@^1.0.0: version "1.5.1" resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.1.tgz#4f3fc94c8b711e9bb2800d185cd6ad20f2a90aff" @@ -2621,7 +4324,7 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge@^4.2.2: +deepmerge@^4.2.2, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -2633,6 +4336,16 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" +defaults@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-3.0.0.tgz#60b9e0003df1018737c2ce3f4289d8f64786c9c4" + integrity sha512-RsqXDEAALjfRTro+IFNKpcPCt0/Cy2FqHSIlnomiJp9YGadpQnrtbRpSgN2+np21qHcIKiva4fiOQGjS9/qR/A== + +defer-to-connect@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== + define-data-property@^1.0.1, define-data-property@^1.1.2, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -2665,25 +4378,20 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -destroy@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - -detect-libc@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d" - integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw== +detect-libc@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== detect-newline@^3.0.0: version "3.1.0" @@ -2708,13 +4416,6 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== -difflib@~0.2.1: - version "0.2.4" - resolved "https://registry.yarnpkg.com/difflib/-/difflib-0.2.4.tgz#b5e30361a6db023176d562892db85940a718f47e" - integrity sha512-9YVwmMb0wQHQNr5J9m6BSj6fk4pfGITGQOOs+D9Fl+INODWFOfvhIU1hNv6GgR1RBoC/9NJcwu77zShxV0kT7w== - dependencies: - heap ">= 0.2.0" - dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -2746,42 +4447,30 @@ dotenv@16.3.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== -dreamopt@~0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/dreamopt/-/dreamopt-0.8.0.tgz#5bcc80be7097e45fc489c342405ab68140a8c1d9" - integrity sha512-vyJTp8+mC+G+5dfgsY+r3ckxlz+QMX40VjPQsZc5gxVAxLmi64TBoVkP54A/pRAXMXsbu2GMMBrZPxNv23waMg== - dependencies: - wordwrap ">=0.0.2" +dotenv@^16.4.7: + version "16.4.7" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26" + integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== -drizzle-kit@^0.20.13: - version "0.20.13" - resolved "https://registry.yarnpkg.com/drizzle-kit/-/drizzle-kit-0.20.13.tgz#6aa45a3b445ecfd05becb7bb679a54a3194f4fe4" - integrity sha512-j9oZSQXNWG+KBJm0Sg3S/zJpncHGKnpqNfFuM4NUxUMGTcihDHhP9SW6Jncqwb5vsP1Xm0a8JLm3PZUIspC/oA== +drizzle-kit@^0.30.1: + version "0.30.1" + resolved "https://registry.yarnpkg.com/drizzle-kit/-/drizzle-kit-0.30.1.tgz#79f000fdd96d837cc63d30b2e4b32d34c2cd4154" + integrity sha512-HmA/NeewvHywhJ2ENXD3KvOuM/+K2dGLJfxVfIHsGwaqKICJnS+Ke2L6UcSrSrtMJLJaT0Im1Qv4TFXfaZShyw== dependencies: - "@drizzle-team/studio" "^0.0.39" + "@drizzle-team/brocli" "^0.10.2" "@esbuild-kit/esm-loader" "^2.5.5" - camelcase "^7.0.1" - chalk "^5.2.0" - commander "^9.4.1" - env-paths "^3.0.0" esbuild "^0.19.7" esbuild-register "^3.5.0" - glob "^8.1.0" - hanji "^0.0.5" - json-diff "0.9.0" - minimatch "^7.4.3" - semver "^7.5.4" - zod "^3.20.2" -drizzle-orm@^0.29.3: - version "0.29.3" - resolved "https://registry.yarnpkg.com/drizzle-orm/-/drizzle-orm-0.29.3.tgz#a9b9beb235bbdf9100e62432390cadbee95345cf" - integrity sha512-uSE027csliGSGYD0pqtM+SAQATMREb3eSM/U8s6r+Y0RFwTKwftnwwSkqx3oS65UBgqDOM0gMTl5UGNpt6lW0A== +drizzle-orm@0.38.3: + version "0.38.3" + resolved "https://registry.yarnpkg.com/drizzle-orm/-/drizzle-orm-0.38.3.tgz#2bdf9a649eda9731cfd3f39b2fdaf6bf844be492" + integrity sha512-w41Y+PquMpSff/QDRGdItG0/aWca+/J3Sda9PPGkTxBtjWQvgU1jxlFBXdjog5tYvTu58uvi3PwR1NuCx0KeZg== -drizzle-zod@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/drizzle-zod/-/drizzle-zod-0.5.1.tgz#4e5efe016dce22ed01063f72f839b07670b2d11e" - integrity sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A== +drizzle-zod@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/drizzle-zod/-/drizzle-zod-0.6.1.tgz#2024a9ece75f27748c7a71ee48a094ee1957fd90" + integrity sha512-huEbUgnsuR8tupnmLiyB2F1I2H9dswI3GfM36IbIqx9i0YUeYjRsDpJVyFVeziUvI1ogT9JHRL2Q03cC4QmvxA== eastasianwidth@^0.2.0: version "0.2.0" @@ -2795,11 +4484,6 @@ ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer "^5.0.1" -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - electron-to-chromium@^1.4.601: version "1.4.641" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.641.tgz#7439993ee3df558bbb78d5459c15cfbde5bf6d60" @@ -2820,10 +4504,36 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +engine.io-client@~6.6.1: + version "6.6.3" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.3.tgz#815393fa24f30b8e6afa8f77ccca2f28146be6de" + integrity sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.17.1" + xmlhttprequest-ssl "~2.1.1" + +engine.io-parser@~5.2.1: + version "5.2.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" + integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== + +engine.io@~6.6.0: + version "6.6.4" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.6.4.tgz#0a89a3e6b6c1d4b0c2a2a637495e7c149ec8d8ee" + integrity sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g== + dependencies: + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.7.2" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.17.1" enhanced-resolve@^5.0.0, enhanced-resolve@^5.15.0, enhanced-resolve@^5.7.0: version "5.15.0" @@ -2833,11 +4543,6 @@ enhanced-resolve@^5.0.0, enhanced-resolve@^5.15.0, enhanced-resolve@^5.7.0: graceful-fs "^4.2.4" tapable "^2.2.0" -env-paths@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-3.0.0.tgz#2f1e89c2f6dbd3408e1b1711dd82d62e317f58da" - integrity sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A== - error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -2939,42 +4644,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@^0.10.61, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: - version "0.10.62" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.62.tgz#5e6adc19a6da524bf3d1e02bbc8960e5eb49a9a5" - integrity sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA== - dependencies: - es6-iterator "^2.0.3" - es6-symbol "^3.1.3" - next-tick "^1.1.0" - -es6-iterator@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" - integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g== - dependencies: - d "1" - es5-ext "^0.10.35" - es6-symbol "^3.1.1" - -es6-symbol@^3.1.1, es6-symbol@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" - integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA== - dependencies: - d "^1.0.1" - ext "^1.1.2" - -es6-weak-map@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" - integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== - dependencies: - d "1" - es5-ext "^0.10.46" - es6-iterator "^2.0.3" - es6-symbol "^3.1.1" - esbuild-register@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/esbuild-register/-/esbuild-register-3.5.0.tgz#449613fb29ab94325c722f560f800dd946dc8ea8" @@ -3229,25 +4898,12 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - -event-emitter@^0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" - integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA== - dependencies: - d "1" - es5-ext "~0.10.14" - events@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^5.0.0: +execa@^5.0.0, execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -3278,49 +4934,20 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" -express@4.18.2: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.1" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.5.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.2.0" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.7" - qs "6.11.0" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" +ext-list@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37" + integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA== + dependencies: + mime-db "^1.28.0" -ext@^1.1.2: - version "1.7.0" - resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" - integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw== +ext-name@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ext-name/-/ext-name-5.0.0.tgz#70781981d183ee15d13993c8822045c506c8f0a6" + integrity sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ== dependencies: - type "^2.7.2" + ext-list "^2.0.0" + sort-keys-length "^1.0.0" external-editor@^3.0.3, external-editor@^3.1.0: version "3.1.0" @@ -3331,6 +4958,16 @@ external-editor@^3.0.3, external-editor@^3.1.0: iconv-lite "^0.4.24" tmp "^0.0.33" +fast-content-type-parse@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz#4087162bf5af3294d4726ff29b334f72e3a1092c" + integrity sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ== + +fast-decode-uri-component@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543" + integrity sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3341,6 +4978,22 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + +fast-glob@^3.2.5: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + fast-glob@^3.2.9: version "3.3.2" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" @@ -3357,16 +5010,92 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-json-stringify@^5.7.0, fast-json-stringify@^5.8.0: + version "5.16.1" + resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz#a6d0c575231a3a08c376a00171d757372f2ca46e" + integrity sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g== + dependencies: + "@fastify/merge-json-schemas" "^0.1.0" + ajv "^8.10.0" + ajv-formats "^3.0.1" + fast-deep-equal "^3.1.3" + fast-uri "^2.1.0" + json-schema-ref-resolver "^1.0.1" + rfdc "^1.2.0" + fast-levenshtein@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-querystring@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/fast-querystring/-/fast-querystring-1.1.2.tgz#a6d24937b4fc6f791b4ee31dcb6f53aeafb89f53" + integrity sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg== + dependencies: + fast-decode-uri-component "^1.0.1" + +fast-redact@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.5.0.tgz#e9ea02f7e57d0cd8438180083e93077e496285e4" + integrity sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A== + fast-safe-stringify@2.1.1, fast-safe-stringify@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== +fast-uri@^2.0.0, fast-uri@^2.1.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-2.4.0.tgz#67eae6fbbe9f25339d5d3f4c4234787b65d7d55e" + integrity sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA== + +fast-uri@^3.0.1: + version "3.0.6" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748" + integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== + +fast-xml-parser@4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" + integrity sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== + dependencies: + strnum "^1.0.5" + +fastify-plugin@^4.0.0: + version "4.5.1" + resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-4.5.1.tgz#44dc6a3cc2cce0988bc09e13f160120bbd91dbee" + integrity sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ== + +fastify@4.28.1: + version "4.28.1" + resolved "https://registry.yarnpkg.com/fastify/-/fastify-4.28.1.tgz#39626dedf445d702ef03818da33064440b469cd1" + integrity sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ== + dependencies: + "@fastify/ajv-compiler" "^3.5.0" + "@fastify/error" "^3.4.0" + "@fastify/fast-json-stringify-compiler" "^4.3.0" + abstract-logging "^2.0.1" + avvio "^8.3.0" + fast-content-type-parse "^1.1.0" + fast-json-stringify "^5.8.0" + find-my-way "^8.0.0" + light-my-request "^5.11.0" + pino "^9.0.0" + process-warning "^3.0.0" + proxy-addr "^2.0.7" + rfdc "^1.3.0" + secure-json-parse "^2.7.0" + semver "^7.5.4" + toad-cache "^3.3.0" + +fastq@^1.17.0, fastq@^1.17.1: + version "1.19.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== + dependencies: + reusify "^1.0.4" + fastq@^1.6.0: version "1.16.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320" @@ -3403,6 +5132,37 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-type@^16.5.4: + version "16.5.4" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd" + integrity sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw== + dependencies: + readable-web-to-node-stream "^3.0.0" + strtok3 "^6.2.4" + token-types "^4.1.1" + +file-type@^19.0.0, file-type@^19.6.0: + version "19.6.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-19.6.0.tgz#b43d8870453363891884cf5e79bb3e4464f2efd3" + integrity sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ== + dependencies: + get-stream "^9.0.1" + strtok3 "^9.0.1" + token-types "^6.0.0" + uint8array-extras "^1.3.0" + +filename-reserved-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz#3d5dd6d4e2d73a3fed2ebc4cd0b3448869a081f7" + integrity sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw== + +filenamify@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/filenamify/-/filenamify-6.0.0.tgz#38def94098c62154c42a41d822650f5f55bcbac2" + integrity sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ== + dependencies: + filename-reserved-regex "^3.0.0" + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" @@ -3410,18 +5170,21 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" + to-regex-range "^5.0.1" + +find-my-way@^8.0.0: + version "8.2.2" + resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-8.2.2.tgz#f3e78bc6ead2da4fdaa201335da3228600ed0285" + integrity sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA== + dependencies: + fast-deep-equal "^3.1.3" + fast-querystring "^1.0.0" + safe-regex2 "^3.1.0" find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" @@ -3439,6 +5202,13 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" +find-versions@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/find-versions/-/find-versions-5.1.0.tgz#973f6739ce20f5e439a27eba8542a4b236c8e685" + integrity sha512-+iwzCJ7C5v5KgcBuueqVoNiHVoQpwiUK5XFLjf0affFTep+Wcw93tPvmb8tqujDNmzhBDPddnWV/qgWSXgq+Hg== + dependencies: + semver-regex "^4.0.5" + flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -3453,6 +5223,11 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -3460,23 +5235,6 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" -for-in@^0.1.3: - version "0.1.8" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" - integrity sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g== - -for-in@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== - -for-own@^0.1.3: - version "0.1.5" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" - integrity sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw== - dependencies: - for-in "^1.0.1" - foreground-child@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" @@ -3503,6 +5261,11 @@ fork-ts-checker-webpack-plugin@9.0.2: semver "^7.3.5" tapable "^2.2.1" +form-data-encoder@^2.1.2: + version "2.1.4" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" + integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== + form-data@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" @@ -3527,11 +5290,6 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - fs-extra@^10.0.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -3541,13 +5299,6 @@ fs-extra@^10.0.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-minipass@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" - integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== - dependencies: - minipass "^3.0.0" - fs-monkey@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.5.tgz#fe450175f0db0d7ea758102e1d84096acb925788" @@ -3583,21 +5334,6 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== -gauge@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" - integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== - dependencies: - aproba "^1.0.3 || ^2.0.0" - color-support "^1.1.2" - console-control-strings "^1.0.0" - has-unicode "^2.0.1" - object-assign "^4.1.1" - signal-exit "^3.0.0" - string-width "^4.2.3" - strip-ansi "^6.0.1" - wide-align "^1.1.2" - gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -3634,11 +5370,19 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -get-stream@^6.0.0: +get-stream@^6.0.0, get-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-stream@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27" + integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== + dependencies: + "@sec-ant/readable-stream" "^0.4.1" + is-stream "^4.0.1" + get-symbol-description@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" @@ -3685,6 +5429,18 @@ glob@10.3.10: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" +glob@^10.3.4: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^7.0.0, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -3697,17 +5453,6 @@ glob@^7.0.0, glob@^7.1.3, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" - integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^5.0.1" - once "^1.3.0" - glob@^9.2.0: version "9.3.5" resolved "https://registry.yarnpkg.com/glob/-/glob-9.3.5.tgz#ca2ed8ca452781a3009685607fdf025a899dfe21" @@ -3756,7 +5501,24 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: +got@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/got/-/got-13.0.0.tgz#a2402862cef27a5d0d1b07c0fb25d12b58175422" + integrity sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA== + dependencies: + "@sindresorhus/is" "^5.2.0" + "@szmarczak/http-timer" "^5.0.1" + cacheable-lookup "^7.0.0" + cacheable-request "^10.2.8" + decompress-response "^6.0.0" + form-data-encoder "^2.1.2" + get-stream "^6.0.1" + http2-wrapper "^2.1.10" + lowercase-keys "^3.0.0" + p-cancelable "^3.0.0" + responselike "^3.0.0" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -3766,14 +5528,6 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -hanji@^0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/hanji/-/hanji-0.0.5.tgz#22a5092e53b2a83ed6172c488ae0d68eb3119213" - integrity sha512-Abxw1Lq+TnYiL4BueXqMau222fPSPMFtya8HdpWsz/xVAhifXou71mPh/kY2+08RgFcVccjG3uZHs6K5HAe3zw== - dependencies: - lodash.throttle "^4.1.1" - sisteransi "^1.0.5" - has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -3830,11 +5584,6 @@ has-tostringtag@^1.0.0, has-tostringtag@^1.0.1, has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" -has-unicode@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== - hasown@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c" @@ -3849,11 +5598,6 @@ hasown@^2.0.1: dependencies: function-bind "^1.1.2" -"heap@>= 0.2.0": - version "0.2.7" - resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.7.tgz#1e6adf711d3f27ce35a81fe3b7bd576c2260a8fc" - integrity sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg== - hexoid@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" @@ -3864,7 +5608,12 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== -http-errors@2.0.0: +http-cache-semantics@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== + +http-errors@2.0.0, http-errors@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== @@ -3875,27 +5624,27 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" -https-proxy-agent@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== +http2-wrapper@^2.1.10: + version "2.2.1" + resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-2.2.1.tgz#310968153dcdedb160d8b72114363ef5fce1f64a" + integrity sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ== dependencies: - agent-base "6" - debug "4" + quick-lru "^5.1.1" + resolve-alpn "^1.2.0" human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -iconv-lite@0.4.24, iconv-lite@^0.4.24: +iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== dependencies: safer-buffer ">= 2.1.2 < 3" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -3981,6 +5730,13 @@ inquirer@9.2.11: strip-ansi "^6.0.1" wrap-ansi "^6.2.0" +inspect-with-kind@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/inspect-with-kind/-/inspect-with-kind-1.0.5.tgz#fce151d4ce89722c82ca8e9860bb96f9167c316c" + integrity sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g== + dependencies: + kind-of "^6.0.2" + internal-slot@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" @@ -3995,6 +5751,21 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== +ioredis@^5.4.1, ioredis@^5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.4.2.tgz#ebb6f1a10b825b2c0fb114763d7e82114a0bee6c" + integrity sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + 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" + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -4035,7 +5806,7 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-buffer@^1.0.2, is-buffer@^1.1.5, is-buffer@~1.1.6: +is-buffer@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== @@ -4059,11 +5830,6 @@ is-date-object@^1.0.1: dependencies: has-tostringtag "^1.0.0" -is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== - is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -4113,17 +5879,10 @@ is-path-inside@^3.0.3: resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== -is-plain-object@^2.0.1: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -is-promise@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" - integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== +is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== is-regex@^1.1.4: version "1.1.4" @@ -4140,11 +5899,16 @@ is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: dependencies: call-bind "^1.0.7" -is-stream@^2.0.0: +is-stream@^2.0.0, is-stream@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== +is-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b" + integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== + is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" @@ -4183,11 +5947,6 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" -is-what@^4.1.8: - version "4.1.16" - resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" - integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== - isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" @@ -4203,11 +5962,6 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== - istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" @@ -4261,7 +6015,7 @@ istanbul-reports@^3.1.3: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -iterare@1.2.1: +iterare@1.2.1, iterare@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/iterare/-/iterare-1.2.1.tgz#139c400ff7363690e33abffa33cbba8920f00042" integrity sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q== @@ -4275,6 +6029,15 @@ jackspeak@^2.3.5: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jest-changed-files@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" @@ -4632,7 +6395,7 @@ jest-worker@^29.7.0: merge-stream "^2.0.0" supports-color "^8.0.0" -jest@^29.5.0: +jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== @@ -4672,20 +6435,18 @@ json-buffer@3.0.1: resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== -json-diff@0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/json-diff/-/json-diff-0.9.0.tgz#e7c536798053cb409113d7403c774849e8a0d7ff" - integrity sha512-cVnggDrVkAAA3OvFfHpFEhOnmcsUpleEKq4d4O8sQWWSH40MBrWstKigVB1kGrgLWzuom+7rRdaCsnBD6VyObQ== - dependencies: - cli-color "^2.0.0" - difflib "~0.2.1" - dreamopt "~0.8.0" - json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== +json-schema-ref-resolver@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz#6586f483b76254784fc1d2120f717bdc9f0a99bf" + integrity sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw== + dependencies: + fast-deep-equal "^3.1.3" + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -4760,10 +6521,10 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" -kareem@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.5.1.tgz#7b8203e11819a8e77a34b3517d3ead206764d15d" - integrity sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA== +kareem@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.6.3.tgz#23168ec8ffb6c1abfd31b7169a6fb1dd285992ac" + integrity sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q== keyv@^4.5.3: version "4.5.4" @@ -4772,35 +6533,16 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" -kind-of@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" - integrity sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg== - dependencies: - is-buffer "^1.0.2" - -kind-of@^3.0.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== - dependencies: - is-buffer "^1.1.5" +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -lazy-cache@^0.2.3: - version "0.2.7" - resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-0.2.7.tgz#7feddf2dcb6edb77d11ef1d117ab5ffdf0ab1b65" - integrity sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ== - -lazy-cache@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" - integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ== - leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -4819,6 +6561,24 @@ libphonenumber-js@^1.10.53: resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.54.tgz#8dfba112f49d1b9c2a160e55f9697f22e50f0841" integrity sha512-P+38dUgJsmh0gzoRDoM4F5jLbyfztkU6PY6eSK6S5HwTi/LPvnwXqVCQZlAy1FxZ5c48q25QhxGQ0pq+WQcSlQ== +light-my-request@6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-6.3.0.tgz#14f048b395db842c1ebca3b5536d1679d9dafd57" + integrity sha512-bWTAPJmeWQH5suJNYwG0f5cs0p6ho9e6f1Ppoxv5qMosY+s9Ir2+ZLvvHcgA7VTDop4zl/NCHhOVVqU+kd++Ow== + dependencies: + cookie "^1.0.1" + process-warning "^4.0.0" + set-cookie-parser "^2.6.0" + +light-my-request@^5.11.0: + version "5.14.0" + resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-5.14.0.tgz#11ddae56de4053fd5c1845cbfbee5c29e8a257e7" + integrity sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA== + dependencies: + cookie "^0.7.0" + process-warning "^3.0.0" + set-cookie-parser "^2.4.1" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -4843,11 +6603,21 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== + lodash.isboolean@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" @@ -4888,11 +6658,6 @@ lodash.once@^4.0.0: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== -lodash.throttle@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" - integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== - lodash@4.17.21, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -4906,6 +6671,16 @@ log-symbols@^4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" +lowercase-keys@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-3.0.0.tgz#c5e7d442e37ead247ae9db117a9d0a467c89d4f2" + integrity sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -4925,12 +6700,10 @@ lru-cache@^6.0.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.1.0.tgz#2098d41c2dc56500e6c88584aa656c84de7d0484" integrity sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag== -lru-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" - integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ== - dependencies: - es5-ext "~0.10.2" +luxon@^3.2.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== magic-string@0.30.5: version "0.30.5" @@ -4939,13 +6712,6 @@ magic-string@0.30.5: dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" -make-dir@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - make-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" @@ -4986,39 +6752,11 @@ memfs@^3.4.1: dependencies: fs-monkey "^1.0.4" -memoizee@^0.4.15: - version "0.4.15" - resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72" - integrity sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ== - dependencies: - d "^1.0.1" - es5-ext "^0.10.53" - es6-weak-map "^2.0.3" - event-emitter "^0.3.5" - is-promise "^2.2.2" - lru-queue "^0.1.0" - next-tick "^1.1.0" - timers-ext "^0.1.7" - memory-pager@^1.0.2: version "1.5.0" resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== -merge-deep@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/merge-deep/-/merge-deep-3.0.3.tgz#1a2b2ae926da8b2ae93a0ac15d90cd1922766003" - integrity sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA== - dependencies: - arr-union "^3.1.0" - clone-deep "^0.2.4" - kind-of "^3.0.2" - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== - merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -5029,7 +6767,7 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@^1.1.2, methods@~1.1.2: +methods@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== @@ -5042,11 +6780,24 @@ micromatch@^4.0.0, micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== +mime-db@^1.28.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" @@ -5054,21 +6805,31 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: dependencies: mime-db "1.52.0" -mime@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - mime@2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +mimic-response@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-4.0.0.tgz#35468b19e7c75d10f5165ea25e75a5ceea7cf70f" + integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== + minimatch@9.0.3, minimatch@^9.0.1: version "9.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" @@ -5083,20 +6844,6 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^7.4.3: - version "7.4.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.6.tgz#845d6f254d8f4a5e4fd6baf44d5f10c8448365fb" - integrity sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw== - dependencies: - brace-expansion "^2.0.1" - minimatch@^8.0.2: version "8.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-8.0.4.tgz#847c1b25c014d4e9a7f68aaf63dedd668a626229" @@ -5104,48 +6851,32 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" +minimatch@^9.0.3, minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -minipass@^3.0.0: - version "3.3.6" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" - integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== - dependencies: - yallist "^4.0.0" - minipass@^4.2.4: version "4.2.8" resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.8.tgz#f0010f64393ecfc1d1ccb5f582bcaf45f48e1a3a" integrity sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ== -minipass@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" - integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== - "minipass@^5.0.0 || ^6.0.2 || ^7.0.0": - version "7.0.4" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" - integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== - -minizlib@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" - integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== - dependencies: - minipass "^3.0.0" - yallist "^4.0.0" + version "7.0.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" + integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== -mixin-object@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" - integrity sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA== - dependencies: - for-in "^0.1.3" - is-extendable "^0.1.1" +minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== mkdirp@^0.5.4: version "0.5.6" @@ -5154,11 +6885,18 @@ mkdirp@^0.5.4: dependencies: minimist "^1.2.6" -mkdirp@^1.0.3: +mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mnemonist@0.39.6: + version "0.39.6" + resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.39.6.tgz#0b3c9b7381d9edf6ce1957e74b25a8ad25732f57" + integrity sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA== + dependencies: + obliterator "^2.0.1" + mongodb-connection-string-url@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.0.tgz#b4f87f92fd8593f3b9365f592515a06d304a1e9c" @@ -5167,27 +6905,27 @@ mongodb-connection-string-url@^3.0.0: "@types/whatwg-url" "^11.0.2" whatwg-url "^13.0.0" -mongodb@6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.3.0.tgz#ec9993b19f7ed2ea715b903fcac6171c9d1d38ca" - integrity sha512-tt0KuGjGtLUhLoU263+xvQmPHEGTw5LbcNC73EoFRYgSHwZt5tsoJC110hDyO1kjQzpgNrpdcSza9PknWN4LrA== +mongodb@~6.15.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.15.0.tgz#4303ce0f7d5e5750749b1cb318dc7a115778f92e" + integrity sha512-ifBhQ0rRzHDzqp9jAQP6OwHSH7dbYIQjD3SbJs9YYk9AikKEettW/9s/tbSFDTpXcRbF+u1aLrhHxDFaYtZpFQ== dependencies: - "@mongodb-js/saslprep" "^1.1.0" - bson "^6.2.0" + "@mongodb-js/saslprep" "^1.1.9" + bson "^6.10.3" mongodb-connection-string-url "^3.0.0" -mongoose@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-8.2.0.tgz#dac98f1a5bfefad8656a0bb085789a2dc079631c" - integrity sha512-la93n6zCYRbPS+c5N9oTDAktvREy5OT9OCljp1Tah0y3+p8UPMTAoabWaLZMdzYruOtF9/9GRf6MasaZjiZP1A== +mongoose@^8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-8.13.0.tgz#31fc1e4efe986f250e86260b32d2b04a39031cc2" + integrity sha512-e/iYV1mPeOkg+SWAMHzt3t42/EZyER3OB1H2pjP9C3vQ+Qb5DMeV9Kb+YCUycKgScA3fbwL7dKG4EpinGlg21g== dependencies: - bson "^6.2.0" - kareem "2.5.1" - mongodb "6.3.0" + bson "^6.10.3" + kareem "2.6.3" + mongodb "~6.15.0" mpath "0.9.0" mquery "5.0.0" ms "2.1.3" - sift "16.0.1" + sift "17.1.3" mpath@0.9.0: version "0.9.0" @@ -5201,25 +6939,41 @@ mquery@5.0.0: dependencies: debug "4.x" -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -multer@1.4.4-lts.1: - version "1.4.4-lts.1" - resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4-lts.1.tgz#24100f701a4611211cfae94ae16ea39bb314e04d" - integrity sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg== +msgpackr-extract@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz#e9d87023de39ce714872f9e9504e3c1996d61012" + integrity sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA== + 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" + +msgpackr@^1.11.2: + version "1.11.2" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.2.tgz#4463b7f7d68f2e24865c395664973562ad24473d" + integrity sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g== + optionalDependencies: + msgpackr-extract "^3.0.2" + +multer@^1.4.5-lts.1: + version "1.4.5-lts.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac" + integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ== dependencies: append-field "^1.0.0" busboy "^1.0.0" @@ -5254,27 +7008,47 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -nestjs-zod@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/nestjs-zod/-/nestjs-zod-3.0.0.tgz#1e8a016d0ad38b6a8169fa504ae39828da75c6db" - integrity sha512-vL9CHShCVj6TmjCVPOd4my46D8d7FdoB4nQvvh+lmVTuzvnwuD+slSxjT4EDdPDWDFtjhfpvQnnkr55/80KHEQ== - dependencies: - merge-deep "^3.0.3" +nestjs-form-data@^1.9.92: + version "1.9.92" + resolved "https://registry.yarnpkg.com/nestjs-form-data/-/nestjs-form-data-1.9.92.tgz#b4ddd03eea33585e41c87541532143cc424681a6" + integrity sha512-HsK2Zsh0ZZF+R7bSJ3LqUax0QQXm7AcSMTO0zknF6B+jewaejU+I74AUoerIg6Iibvg2ekDeOyq91WvS2YghxQ== + dependencies: + uid "^2.0.0" + busboy "^1.6.0" + concat-stream "^2.0.0" + file-type "^16.5.4" + mkdirp "^1.0.4" + type-is "^1.6.18" + +nestjs-i18n@^10.5.1: + version "10.5.1" + resolved "https://registry.yarnpkg.com/nestjs-i18n/-/nestjs-i18n-10.5.1.tgz#d8400c64edeb0541d3b081bb02bc8d5263c295c2" + integrity sha512-cJJFz+RUfav23QACpGCq1pdXNLYC3tBesrP14RGoE/YYcD4xosQPX2eyjvDNuo0Ti63Xtn6j57wDNEUKrZqmSw== + dependencies: + accept-language-parser "^1.5.0" + chokidar "^3.6.0" + cookie "^0.7.0" + iterare "^1.2.1" + js-yaml "^4.1.0" + string-format "^2.0.0" -next-tick@1, next-tick@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" - integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== +nestjs-zod@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/nestjs-zod/-/nestjs-zod-4.2.0.tgz#0c2f00e791f827493b8226336bf157ed47ef8929" + integrity sha512-ToowaeHS3TApFfX1yKR9NaxVk1ulCVQw0DO3vFaSAW9mLSdOXqFBEg0kHWVVoL630O7EVGveXiCW0r5kL7PdXg== + dependencies: + "@nest-zod/z" "*" + deepmerge "^4.3.1" -node-abort-controller@^3.0.1: +node-abort-controller@^3.0.1, node-abort-controller@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== -node-addon-api@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.0.tgz#71f609369379c08e251c558527a107107b5e0fdb" - integrity sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g== +node-addon-api@^8.1.0: + version "8.3.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.3.1.tgz#53bc8a4f8dbde3de787b9828059da94ba9fd4eed" + integrity sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA== node-emoji@1.11.0: version "1.11.0" @@ -5283,13 +7057,25 @@ node-emoji@1.11.0: dependencies: lodash "^4.17.21" -node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@^2.6.1: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== dependencies: whatwg-url "^5.0.0" +node-gyp-build-optional-packages@5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz#522f50c2d53134d7f3a76cd7255de4ab6c96a3a4" + integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== + dependencies: + detect-libc "^2.0.1" + +node-gyp-build@^4.8.1: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5300,18 +7086,16 @@ node-releases@^2.0.14: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== - dependencies: - abbrev "1" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +normalize-url@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-8.0.1.tgz#9b7d96af9836577c58f5883e939365fa15623a4a" + integrity sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w== + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -5319,21 +7103,16 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -npmlog@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" - integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== - dependencies: - are-we-there-yet "^2.0.0" - console-control-strings "^1.1.0" - gauge "^3.0.0" - set-blocking "^2.0.0" - object-assign@^4, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== +object-hash@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + object-inspect@^1.13.1, object-inspect@^1.9.0: version "1.13.1" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" @@ -5383,12 +7162,20 @@ object.values@^1.1.7: define-properties "^1.2.0" es-abstract "^1.22.1" -on-finished@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" +obliterator@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-2.0.5.tgz#031e0145354b0c18840336ae51d41e7d6d2c76aa" + integrity sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw== + +obuf@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + +on-exit-leak-free@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" + integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== once@^1.3.0, once@^1.4.0: version "1.4.0" @@ -5436,6 +7223,11 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== +p-cancelable@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" + integrity sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw== + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -5469,10 +7261,10 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -packet-reader@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" - integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== parent-module@^1.0.0: version "1.0.1" @@ -5491,11 +7283,6 @@ parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - passport-jwt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" @@ -5537,45 +7324,83 @@ path-scurry@^1.10.1, path-scurry@^1.6.1: lru-cache "^9.1.1 || ^10.0.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-to-regexp@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.2.0.tgz#fa7877ecbc495c601907562222453c43cc204a5f" integrity sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA== +path-to-regexp@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" + integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw== + +path-to-regexp@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" + integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +peek-readable@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72" + integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg== + +peek-readable@^5.3.1: + version "5.4.2" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-5.4.2.tgz#aff1e1ba27a7d6911ddb103f35252ffc1787af49" + integrity sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg== + +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== + pg-cloudflare@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q== -pg-connection-string@^2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.2.tgz#713d82053de4e2bd166fab70cd4f26ad36aab475" - integrity sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA== +pg-connection-string@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37" + integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA== pg-int8@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-pool@^3.6.1: - version "3.6.1" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.1.tgz#5a902eda79a8d7e3c928b77abf776b3cb7d351f7" - integrity sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og== +pg-numeric@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pg-numeric/-/pg-numeric-1.0.2.tgz#816d9a44026086ae8ae74839acd6a09b0636aa3a" + integrity sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw== + +pg-pool@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.8.0.tgz#e6bce7fc4506a8d6106551363fc5283e5445b776" + integrity sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw== -pg-protocol@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.0.tgz#4c91613c0315349363af2084608db843502f8833" - integrity sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q== +pg-protocol@*: + version "1.7.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.7.0.tgz#ec037c87c20515372692edac8b63cf4405448a93" + integrity sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ== + +pg-protocol@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.8.0.tgz#c707101dd07813868035a44571488e4b98639d48" + integrity sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g== pg-types@^2.1.0: version "2.2.0" @@ -5588,16 +7413,27 @@ pg-types@^2.1.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@^8.11.3: - version "8.11.3" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.11.3.tgz#d7db6e3fe268fcedd65b8e4599cda0b8b4bf76cb" - integrity sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g== - dependencies: - buffer-writer "2.0.0" - packet-reader "1.0.0" - pg-connection-string "^2.6.2" - pg-pool "^3.6.1" - pg-protocol "^1.6.0" +pg-types@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-4.0.2.tgz#399209a57c326f162461faa870145bb0f918b76d" + integrity sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng== + dependencies: + pg-int8 "1.0.1" + pg-numeric "1.0.2" + postgres-array "~3.0.1" + postgres-bytea "~3.0.0" + postgres-date "~2.1.0" + postgres-interval "^3.0.0" + postgres-range "^1.1.1" + +pg@^8.14.1: + version "8.14.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.14.1.tgz#2e3d1f287b64797cdfc8d1ba000f61a7ff8d66ed" + integrity sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw== + dependencies: + pg-connection-string "^2.7.0" + pg-pool "^3.8.0" + pg-protocol "^1.8.0" pg-types "^2.1.0" pgpass "1.x" optionalDependencies: @@ -5625,11 +7461,47 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pino-abstract-transport@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz#de241578406ac7b8a33ce0d77ae6e8a0b3b68a60" + integrity sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw== + dependencies: + split2 "^4.0.0" + +pino-std-serializers@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" + integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== + +pino@^9.0.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.6.0.tgz#6bc628159ba0cc81806d286718903b7fc6b13169" + integrity sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^2.0.0" + pino-std-serializers "^7.0.0" + process-warning "^4.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + pirates@^4.0.4: version "4.0.6" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== +piscina@^4.3.1: + version "4.9.2" + resolved "https://registry.yarnpkg.com/piscina/-/piscina-4.9.2.tgz#80f2c2375231720337c703e443941adfac8caf75" + integrity sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ== + optionalDependencies: + "@napi-rs/nice" "^1.0.1" + pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -5652,16 +7524,33 @@ postgres-array@~2.0.0: resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== +postgres-array@~3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-3.0.2.tgz#68d6182cb0f7f152a7e60dc6a6889ed74b0a5f98" + integrity sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog== + postgres-bytea@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== +postgres-bytea@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-3.0.0.tgz#9048dc461ac7ba70a6a42d109221619ecd1cb089" + integrity sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw== + dependencies: + obuf "~1.1.2" + postgres-date@~1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== +postgres-date@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-2.1.0.tgz#b85d3c1fb6fb3c6c8db1e9942a13a3bf625189d0" + integrity sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA== + postgres-interval@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" @@ -5669,6 +7558,16 @@ postgres-interval@^1.1.0: dependencies: xtend "^4.0.0" +postgres-interval@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-3.0.0.tgz#baf7a8b3ebab19b7f38f07566c7aab0962f0c86a" + integrity sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw== + +postgres-range@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863" + integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -5700,6 +7599,16 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +process-warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-3.0.0.tgz#96e5b88884187a1dce6f5c3166d611132058710b" + integrity sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ== + +process-warning@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-4.0.1.tgz#5c1db66007c67c756e4e09eb170cdece15da32fb" + integrity sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q== + prompts@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" @@ -5708,7 +7617,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -proxy-addr@~2.0.7: +proxy-addr@^2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== @@ -5716,6 +7625,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + punycode@^2.1.0, punycode@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -5726,13 +7640,6 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.4.tgz#50b737f6a925468679bff00ad20eade53f37d5c7" integrity sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA== -qs@6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== - dependencies: - side-channel "^1.0.4" - qs@^6.11.0: version "6.11.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" @@ -5745,6 +7652,16 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -5752,31 +7669,6 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - -raw-body@2.5.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" - integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - react-is@^18.0.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" @@ -5795,7 +7687,7 @@ readable-stream@^2.2.2: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.4.0, readable-stream@^3.6.0: +readable-stream@^3.0.2, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -5804,6 +7696,13 @@ readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-web-to-node-stream@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" + integrity sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw== + dependencies: + readable-stream "^3.6.0" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -5811,6 +7710,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +real-require@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" + integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -5818,6 +7722,18 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + reflect-metadata@^0.1.13: version "0.1.14" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.14.tgz#24cf721fe60677146bb77eeb0e1f9dece3d65859" @@ -5848,6 +7764,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +resolve-alpn@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" + integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -5884,6 +7805,13 @@ resolve@^1.1.6, resolve@^1.20.0, resolve@^1.22.4: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +responselike@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-3.0.0.tgz#20decb6c298aff0dbee1c355ca95461d42823626" + integrity sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg== + dependencies: + lowercase-keys "^3.0.0" + restore-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" @@ -5892,11 +7820,21 @@ restore-cursor@^3.1.0: onetime "^5.1.0" signal-exit "^3.0.2" +ret@~0.4.0: + version "0.4.3" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.4.3.tgz#5243fa30e704a2e78a9b9b1e86079e15891aa85c" + integrity sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfdc@^1.2.0, rfdc@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + rimraf@4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.4.1.tgz#bd33364f67021c5b79e93d7f4fa0568c7c21b755" @@ -5964,6 +7902,18 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" +safe-regex2@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/safe-regex2/-/safe-regex2-3.1.0.tgz#fd7ec23908e2c730e1ce7359a5b72883a87d2763" + integrity sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug== + dependencies: + ret "~0.4.0" + +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + "safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -5978,7 +7928,31 @@ schema-utils@^3.1.1, schema-utils@^3.2.0: ajv "^6.12.5" ajv-keywords "^3.5.2" -semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: +secure-json-parse@^2.4.0, secure-json-parse@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" + integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== + +seek-bzip@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-2.0.0.tgz#f0478ab6acd0ac72345d18dc7525dd84d3c706a2" + integrity sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg== + dependencies: + commander "^6.0.0" + +semver-regex@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-4.0.5.tgz#fbfa36c7ba70461311f5debcb3928821eb4f9180" + integrity sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw== + +semver-truncate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/semver-truncate/-/semver-truncate-3.0.0.tgz#0e3b4825d4a4225d8ae6e7c72231182b42edba40" + integrity sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg== + dependencies: + semver "^7.3.5" + +semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -5990,25 +7964,6 @@ semver@^7.3.4, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4: dependencies: lru-cache "^6.0.0" -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== - 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" - serialize-javascript@^6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" @@ -6016,20 +7971,10 @@ serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.18.0" - -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +set-cookie-parser@^2.4.1, set-cookie-parser@^2.6.0: + version "2.7.1" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz#3016f150072202dfbe90fadee053573cc89d2943" + integrity sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ== set-function-length@^1.1.1: version "1.2.0" @@ -6069,16 +8014,6 @@ setprototypeof@1.2.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -shallow-clone@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-0.1.2.tgz#5909e874ba77106d73ac414cfec1ffca87d97060" - integrity sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw== - dependencies: - is-extendable "^0.1.1" - kind-of "^2.0.1" - lazy-cache "^0.2.3" - mixin-object "^2.0.1" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -6109,12 +8044,12 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" -sift@16.0.1: - version "16.0.1" - resolved "https://registry.yarnpkg.com/sift/-/sift-16.0.1.tgz#e9c2ccc72191585008cf3e36fc447b2d2633a053" - integrity sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ== +sift@17.1.3: + version "17.1.3" + resolved "https://registry.yarnpkg.com/sift/-/sift-17.1.3.tgz#9d2000d4d41586880b0079b5183d839c7a142bf7" + integrity sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ== -signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: +signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -6129,7 +8064,7 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -slash@^3.0.0: +slash@3.0.0, slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== @@ -6139,6 +8074,66 @@ slugify@^1.6.6: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== +socket.io-adapter@~2.5.2: + version "2.5.5" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz#c7a1f9c703d7756844751b6ff9abfc1780664082" + integrity sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg== + dependencies: + debug "~4.3.4" + ws "~8.17.1" + +socket.io-client@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.8.1.tgz#1941eca135a5490b94281d0323fe2a35f6f291cb" + integrity sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.2" + engine.io-client "~6.6.1" + socket.io-parser "~4.2.4" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + +socket.io@4.8.1, socket.io@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.1.tgz#fa0eaff965cc97fdf4245e8d4794618459f7558a" + integrity sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg== + dependencies: + accepts "~1.3.4" + base64id "~2.0.0" + cors "~2.8.5" + debug "~4.3.2" + engine.io "~6.6.0" + socket.io-adapter "~2.5.2" + socket.io-parser "~4.2.4" + +sonic-boom@^4.0.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-4.2.0.tgz#e59a525f831210fa4ef1896428338641ac1c124d" + integrity sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww== + dependencies: + atomic-sleep "^1.0.0" + +sort-keys-length@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188" + integrity sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw== + dependencies: + sort-keys "^1.0.0" + +sort-keys@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" + integrity sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg== + dependencies: + is-plain-obj "^1.0.0" + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" @@ -6155,7 +8150,7 @@ source-map-support@0.5.21, source-map-support@^0.5.21, source-map-support@~0.5.2 buffer-from "^1.0.0" source-map "^0.6.0" -source-map@0.7.4, source-map@^0.7.4: +source-map@0.7.4, source-map@^0.7.3, source-map@^0.7.4: version "0.7.4" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== @@ -6172,7 +8167,7 @@ sparse-bitfield@^3.0.3: dependencies: memory-pager "^1.0.2" -split2@^4.1.0: +split2@^4.0.0, split2@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== @@ -6189,16 +8184,41 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + statuses@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +stream-wormhole@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stream-wormhole/-/stream-wormhole-1.1.0.tgz#300aff46ced553cfec642a05251885417693c33d" + integrity sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew== + streamsearch@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +streamx@^2.15.0: + version "2.22.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.22.0.tgz#cd7b5e57c95aaef0ff9b2aef7905afa62ec6e4a7" + integrity sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw== + dependencies: + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + optionalDependencies: + bare-events "^2.2.0" + +string-format@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string-format/-/string-format-2.0.0.tgz#f2df2e7097440d3b65de31b6d40d54c96eaffb9b" + integrity sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA== + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -6207,7 +8227,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6266,7 +8295,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6290,6 +8326,14 @@ strip-bom@^4.0.0: resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== +strip-dirs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-dirs/-/strip-dirs-3.0.0.tgz#7c9a5d7822ce079a9db40387a4b20d5654746f42" + integrity sha512-I0sdgcFTfKQlUPZyAqPJmSG3HLO9rWDFnxonnIbskYNM3DwFOeTNB5KzVq3dA1GdRAc/25b5Y7UO2TQfKWw4aQ== + dependencies: + inspect-with-kind "^1.0.5" + is-plain-obj "^1.1.0" + strip-final-newline@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" @@ -6300,6 +8344,27 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strnum@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" + integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== + +strtok3@^6.2.4: + version "6.3.0" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.3.0.tgz#358b80ffe6d5d5620e19a073aa78ce947a90f9a0" + integrity sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw== + dependencies: + "@tokenizer/token" "^0.3.0" + peek-readable "^4.1.0" + +strtok3@^9.0.1: + version "9.1.1" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-9.1.1.tgz#f8feb188b3fcdbf9b8819cc9211a824c3731df38" + integrity sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw== + dependencies: + "@tokenizer/token" "^0.3.0" + peek-readable "^5.3.1" + superagent@^8.1.2: version "8.1.2" resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b" @@ -6316,13 +8381,6 @@ superagent@^8.1.2: qs "^6.11.0" semver "^7.3.8" -superjson@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/superjson/-/superjson-2.2.1.tgz#9377a7fa80fedb10c851c9dbffd942d4bcf79733" - integrity sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA== - dependencies: - copy-anything "^3.0.2" - supertest@^6.3.4: version "6.3.4" resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.3.4.tgz#2145c250570c2ea5d337db3552dbfb78a2286218" @@ -6357,6 +8415,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swagger-themes@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/swagger-themes/-/swagger-themes-1.4.3.tgz#b2a0abddffe9a41ccc7e40f0cf9ea8a4b1b50a48" + integrity sha512-1G0CqJC1IBbNxkAOyJoREd9hfwXH1R6+3GOFxLhQho2w2i+AbaJqkF4mTJhkce4yhaEMUXvv4KKu1YO/qpe6nQ== + swagger-ui-dist@5.11.0: version "5.11.0" resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.11.0.tgz#9bcfd75278b1fa9c36fe52f206f8fc611470547c" @@ -6380,17 +8443,14 @@ tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== -tar@^6.1.11: - version "6.2.0" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73" - integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^5.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" +tar-stream@^3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" + integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== + dependencies: + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" terser-webpack-plugin@^5.3.7: version "5.3.10" @@ -6422,24 +8482,30 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-decoder@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" + integrity sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA== + dependencies: + b4a "^1.6.4" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -through@^2.3.6: +thread-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" + integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== + dependencies: + real-require "^0.2.0" + +through@^2.3.6, through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== -timers-ext@^0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6" - integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ== - dependencies: - es5-ext "~0.10.46" - next-tick "1" - tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -6464,11 +8530,32 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toad-cache@^3.3.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/toad-cache/-/toad-cache-3.7.0.tgz#b9b63304ea7c45ec34d91f1d2fa513517025c441" + integrity sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw== + toidentifier@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +token-types@^4.1.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-4.2.1.tgz#0f897f03665846982806e138977dbe72d44df753" + integrity sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ== + dependencies: + "@tokenizer/token" "^0.3.0" + ieee754 "^1.2.1" + +token-types@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-6.0.0.tgz#1ab26be1ef9c434853500c071acfe5c8dd6544a3" + integrity sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA== + dependencies: + "@tokenizer/token" "^0.3.0" + ieee754 "^1.2.1" + tr46@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" @@ -6516,7 +8603,7 @@ ts-loader@^9.4.3: semver "^7.3.4" source-map "^0.7.4" -ts-node@^10.9.1: +ts-node@^10.9.2: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== @@ -6568,6 +8655,16 @@ tslib@2.6.2, tslib@^2.1.0, tslib@^2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== + +tslib@2.8.1, tslib@^2.0.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -6590,7 +8687,7 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== -type-is@^1.6.4, type-is@~1.6.18: +type-is@^1.6.18, type-is@^1.6.4: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -6598,16 +8695,6 @@ type-is@^1.6.4, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -type@^1.0.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" - integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== - -type@^2.7.2: - version "2.7.2" - resolved "https://registry.yarnpkg.com/type/-/type-2.7.2.tgz#2376a15a3a28b1efa0f5350dcf72d24df6ef98d0" - integrity sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw== - typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" @@ -6662,13 +8749,18 @@ typescript@5.3.3, typescript@^5.1.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== -uid@2.0.2: +uid@2.0.2, uid@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/uid/-/uid-2.0.2.tgz#4b5782abf0f2feeefc00fa88006b2b3b7af3e3b9" integrity sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g== dependencies: "@lukeed/csprng" "^1.0.0" +uint8array-extras@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/uint8array-extras/-/uint8array-extras-1.4.0.tgz#e42a678a6dd335ec2d21661333ed42f44ae7cc74" + integrity sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -6679,21 +8771,29 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +unbzip2-stream@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" + integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== + dependencies: + buffer "^5.2.1" + through "^2.3.8" + undici-types@~5.26.4: version "5.26.5" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + universalify@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - update-browserslist-db@^1.0.13: version "1.0.13" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" @@ -6714,17 +8814,12 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - uuid@9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== -uuid@^9.0.1: +uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== @@ -6748,7 +8843,7 @@ validator@^13.9.0: resolved "https://registry.yarnpkg.com/validator/-/validator-13.11.0.tgz#23ab3fd59290c61248364eabf4067f04955fbb1b" integrity sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ== -vary@^1, vary@~1.1.2: +vary@^1: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== @@ -6870,20 +8965,7 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -wide-align@^1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" - integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== - dependencies: - string-width "^1.0.2 || 2 || 3 || 4" - -wordwrap@>=0.0.2: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== - -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -6901,6 +8983,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -6923,6 +9014,16 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" +ws@~8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + +xmlhttprequest-ssl@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz#e9e8023b3f29ef34b97a859f584c5e6c61418e23" + integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ== + xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" @@ -6961,6 +9062,14 @@ yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" +yauzl@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-3.2.0.tgz#7b6cb548f09a48a6177ea0be8ece48deb7da45c0" + integrity sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w== + dependencies: + buffer-crc32 "~0.2.3" + pend "~1.2.0" + yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" @@ -6971,7 +9080,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.20.2, zod@^3.22.4: - version "3.22.4" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" - integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg== +zod@^3.24.2: + version "3.24.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3" + integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==