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..60576fe 100644 --- a/.example.env +++ b/.example.env @@ -1,4 +1,10 @@ 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= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d88ec97 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM node:20-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:20-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 + +# 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 3000 + +# Start the application +CMD ["node", "dist/main"] 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..27000ab 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,10 +6,11 @@ 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", + // driver: "pg", + dialect: "postgresql", dbCredentials: { - connectionString: process.env.POSTGRESQL_URL, + url: process.env.POSTGRESQL_URL, }, } satisfies Config; diff --git a/nest-cli.json b/nest-cli.json index f9aa683..a9a0e67 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,13 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { + "assets": [ + { + "include": "i18n/messages/**/*", + "watchAssets": true, + "outDir": "dist" + } + ], "deleteOutDir": true } } diff --git a/package.json b/package.json index 992458a..83095d1 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,17 +19,22 @@ "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:seed": "node -r esbuild-register test/helpers/seed/index.ts" }, "dependencies": { + "@aws-sdk/client-s3": "^3.726.1", + "@liaoliaots/nestjs-redis": "^10.0.0", "@lukeed/ms": "^2.0.2", + "@nestjs/axios": "^3.1.3", + "@nestjs/bullmq": "^11.0.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", @@ -36,23 +42,40 @@ "@nestjs/mongoose": "^10.0.4", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-socket.io": "^11.0.8", "@nestjs/swagger": "^7.2.0", "@nestjs/throttler": "^5.1.1", + "@nestjs/websockets": "^11.0.8", + "@sesamecare-oss/redlock": "^1.4.0", + "@supercharge/request-ip": "^1.2.0", + "@vvo/tzdb": "^6.157.0", "argon2": "^0.31.2", + "axios": "^1.7.9", + "bullmq": "^5.40.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie": "^1.0.2", "cookie-parser": "^1.4.6", - "drizzle-orm": "^0.29.3", - "drizzle-zod": "^0.5.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "dotenv": "^16.4.7", + "drizzle-orm": "0.40.0", + "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", + "multer": "^1.4.5-lts.1", + "nestjs-form-data": "^1.9.92", + "nestjs-i18n": "^10.5.0", + "nestjs-zod": "^4.2.0", "passport-jwt": "^4.0.1", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "slugify": "^1.6.6", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1", "uuid": "^9.0.1", "zod": "^3.22.4" }, @@ -64,13 +87,17 @@ "@types/cookie-parser": "^1.4.6", "@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", @@ -80,7 +107,7 @@ "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/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/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..5d57fe9 --- /dev/null +++ b/src/@base/drizzle/drizzle.module.ts @@ -0,0 +1,90 @@ +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 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 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, + ...orders, + ...paymentMethods, + ...dishModifiers, + ...discounts, + ...dishesMenus, + ...workshifts, + ...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" ? true : false, + }); + + return drizzle(pool, { + schema, + casing: "snake_case", + }) 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..b77a0db --- /dev/null +++ b/src/@base/drizzle/schema/discounts.ts @@ -0,0 +1,84 @@ +import { dayOfWeekEnum } from "@postgress-db/schema/general"; +import { orderFromEnum, orderTypeEnum } from "@postgress-db/schema/orders"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { relations } from "drizzle-orm"; +import { + boolean, + integer, + pgTable, + primaryKey, + text, + 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"), + applyByPromocode: boolean("apply_by_promocode").notNull().default(false), + applyForFirstOrder: boolean("apply_for_first_order").notNull().default(false), + applyByDefault: boolean("apply_by_default").notNull().default(false), + + // Boolean flags // + isEnabled: boolean("isEnabled").notNull().default(true), + + // Valid time // + startHour: integer("start_hour"), + endHour: integer("end_hour"), + activeFrom: timestamp("active_from").notNull(), + activeTo: timestamp("active_to").notNull(), + + // Timestamps // + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export type IDiscount = typeof discounts.$inferSelect; + +export const discountRelations = relations(discounts, ({ many }) => ({ + discountsToRestaurants: many(discountsToRestaurants), +})); + +export const discountsToRestaurants = pgTable( + "discounts_to_restaurants", + { + discountId: uuid("discount_id").notNull(), + restaurantId: uuid("restaurant_id").notNull(), + }, + (t) => [ + primaryKey({ + columns: [t.discountId, t.restaurantId], + }), + ], +); + +export const discountToRestaurantRelations = relations( + discountsToRestaurants, + ({ one }) => ({ + discount: one(discounts, { + fields: [discountsToRestaurants.discountId], + references: [discounts.id], + }), + restaurant: one(restaurants, { + fields: [discountsToRestaurants.restaurantId], + references: [restaurants.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..af6e163 --- /dev/null +++ b/src/@base/drizzle/schema/dish-categories.ts @@ -0,0 +1,47 @@ +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; +import { dishesToCategories } from "@postgress-db/schema/many-to-many"; +import { relations } from "drizzle-orm"; +import { + boolean, + 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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export type IDishCategory = typeof dishCategories.$inferSelect; + +export const dishCategoryRelations = relations( + dishCategories, + ({ one, many }) => ({ + dishesToCategories: many(dishesToCategories), + menu: one(dishesMenus, { + fields: [dishCategories.menuId], + references: [dishesMenus.id], + }), + }), +); diff --git a/src/@base/drizzle/schema/dish-modifiers.ts b/src/@base/drizzle/schema/dish-modifiers.ts new file mode 100644 index 0000000..181798d --- /dev/null +++ b/src/@base/drizzle/schema/dish-modifiers.ts @@ -0,0 +1,69 @@ +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + removedAt: timestamp("removed_at"), +}); + +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..5940882 --- /dev/null +++ b/src/@base/drizzle/schema/dishes-menus.ts @@ -0,0 +1,70 @@ +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + removedAt: timestamp("removed_at"), +}); + +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..520e84b --- /dev/null +++ b/src/@base/drizzle/schema/dishes.ts @@ -0,0 +1,146 @@ +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; +import { currencyEnum } from "@postgress-db/schema/general"; +import { + dishesToCategories, + 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, + 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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (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").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 dishRelations = relations(dishes, ({ one, many }) => ({ + dishesToCategories: many(dishesToCategories), + dishesToImages: many(dishesToImages), + dishesToWorkshops: many(dishesToWorkshops), + dishesToRestaurants: many(dishesToRestaurants), + orderDishes: many(orderDishes), + menu: one(dishesMenus, { + fields: [dishes.menuId], + references: [dishesMenus.id], + }), +})); diff --git a/src/@base/drizzle/schema/files.ts b/src/@base/drizzle/schema/files.ts new file mode 100644 index 0000000..f1a5848 --- /dev/null +++ b/src/@base/drizzle/schema/files.ts @@ -0,0 +1,43 @@ +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").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..c8d03e7 --- /dev/null +++ b/src/@base/drizzle/schema/general.ts @@ -0,0 +1,22 @@ +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]; diff --git a/src/@base/drizzle/schema/guests.ts b/src/@base/drizzle/schema/guests.ts new file mode 100644 index 0000000..fc5d039 --- /dev/null +++ b/src/@base/drizzle/schema/guests.ts @@ -0,0 +1,20 @@ +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").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export type IGuest = typeof guests.$inferSelect; + +export const guestRelations = relations(guests, ({ many }) => ({ + orders: many(orders), +})); 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..49388e4 --- /dev/null +++ b/src/@base/drizzle/schema/many-to-many.ts @@ -0,0 +1,68 @@ +import { dishCategories } from "@postgress-db/schema/dish-categories"; +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 dish categories relation // +// ----------------------------------- // +export const dishesToCategories = pgTable( + "dishes_to_categories", + { + dishId: uuid("dish_id").notNull(), + dishCategoryId: uuid("dish_category_id").notNull(), + }, + (t) => [ + primaryKey({ + columns: [t.dishId, t.dishCategoryId], + }), + ], +); + +export type IDishesToCategories = typeof dishesToCategories.$inferSelect; + +export const dishesToCategoriesRelations = relations( + dishesToCategories, + ({ one }) => ({ + dish: one(dishes, { + fields: [dishesToCategories.dishId], + references: [dishes.id], + }), + dishCategory: one(dishCategories, { + fields: [dishesToCategories.dishCategoryId], + references: [dishCategories.id], + }), + }), +); + +// ----------------------------------- // +// 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..1c0a919 --- /dev/null +++ b/src/@base/drizzle/schema/order-deliveries.ts @@ -0,0 +1,61 @@ +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + dispatchedAt: timestamp("dispatched_at"), + estimatedDeliveryAt: timestamp("estimated_delivery_at"), + deliveredAt: timestamp("delivered_at"), +}); + +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..3076d68 --- /dev/null +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -0,0 +1,131 @@ +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + cookingAt: timestamp("cooking_at"), + readyAt: timestamp("ready_at"), + removedAt: timestamp("removed_at"), + }, + (table) => [index("order_dishes_order_id_idx").on(table.orderId)], +); + +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +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/orders.ts b/src/@base/drizzle/schema/orders.ts new file mode 100644 index 0000000..992ee69 --- /dev/null +++ b/src/@base/drizzle/schema/orders.ts @@ -0,0 +1,147 @@ +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 { paymentMethods } from "@postgress-db/schema/payment-methods"; +import { restaurants } from "@postgress-db/schema/restaurants"; +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 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 orderTypeEnum = pgEnum("order_type_enum", [ + "hall", + "banquet", + "takeaway", + "delivery", +]); + +export const ZodOrderTypeEnum = z.enum(orderTypeEnum.enumValues); + +export type OrderTypeEnum = typeof ZodOrderTypeEnum._type; + +export const orderNumberBroneering = pgTable("order_number_broneering", { + id: uuid("id").defaultRandom().primaryKey(), + number: text("number").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + +export const orders = pgTable( + "orders", + { + id: uuid("id").defaultRandom().primaryKey(), + + // Links // + guestId: uuid("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 // + 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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + cookingAt: timestamp("cooking_at"), + completedAt: timestamp("completed_at"), + removedAt: timestamp("removed_at"), + delayedTo: timestamp("delayed_to"), + }, + (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), + ], +); + +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), +})); diff --git a/src/@base/drizzle/schema/payment-methods.ts b/src/@base/drizzle/schema/payment-methods.ts new file mode 100644 index 0000000..382043c --- /dev/null +++ b/src/@base/drizzle/schema/payment-methods.ts @@ -0,0 +1,62 @@ +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + removedAt: timestamp("removed_at"), +}); + +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..cb2c687 --- /dev/null +++ b/src/@base/drizzle/schema/restaurant-workshop.ts @@ -0,0 +1,69 @@ +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +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").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..8844972 --- /dev/null +++ b/src/@base/drizzle/schema/restaurants.ts @@ -0,0 +1,112 @@ +import { discountsToRestaurants } from "@postgress-db/schema/discounts"; +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, + 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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +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), + discountsToRestaurants: many(discountsToRestaurants), + 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..8fd33ed --- /dev/null +++ b/src/@base/drizzle/schema/sessions.ts @@ -0,0 +1,26 @@ +import { relations } from "drizzle-orm"; +import { boolean, 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").notNull().defaultNow(), + refreshedAt: timestamp("refreshed_at").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").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/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts new file mode 100644 index 0000000..a0effa4 --- /dev/null +++ b/src/@base/drizzle/schema/workers.ts @@ -0,0 +1,129 @@ +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 { 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"), + firedAt: timestamp("fired_at"), + onlineAt: timestamp("online_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +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), +})); + +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-payment-category.ts b/src/@base/drizzle/schema/workshift-payment-category.ts new file mode 100644 index 0000000..6159617 --- /dev/null +++ b/src/@base/drizzle/schema/workshift-payment-category.ts @@ -0,0 +1,57 @@ +import { restaurants } from "@postgress-db/schema/restaurants"; +import { + workshiftPayments, + workshiftPaymentTypeEnum, +} from "@postgress-db/schema/workshift-payments"; +import { relations } from "drizzle-orm"; +import { + boolean, + pgTable, + serial, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + removedAt: timestamp("removed_at"), + }, +); + +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..1617ad9 --- /dev/null +++ b/src/@base/drizzle/schema/workshift-payments.ts @@ -0,0 +1,77 @@ +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, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} 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 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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + removedAt: timestamp("removed_at"), +}); + +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..f48e2ef --- /dev/null +++ b/src/@base/drizzle/schema/workshifts.ts @@ -0,0 +1,96 @@ +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").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + openedAt: timestamp("opened_at"), + closedAt: timestamp("closed_at"), +}); + +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..f3e9455 --- /dev/null +++ b/src/@base/redis/channels.ts @@ -0,0 +1,6 @@ +export enum RedisChannels { + COMMON = 1, + BULLMQ = 2, + SOCKET = 3, + REDLOCK = 4, +} 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..1487c0f --- /dev/null +++ b/src/@base/snapshots/schemas/snapshot.schema.ts @@ -0,0 +1,57 @@ +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({ required: true, enum: SnapshotModel }) + model: SnapshotModel; + + /** + * The action that was taken + */ + @Prop({ 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..009147c 100644 --- a/src/@core/config/app.ts +++ b/src/@core/config/app.ts @@ -1,10 +1,21 @@ +import { HttpExceptionFilter } from "@core/errors/http-exception-filter"; import { INestApplication } from "@nestjs/common"; import * as cookieParser from "cookie-parser"; +import { I18nValidationPipe } from "nestjs-i18n"; export const configApp = (app: INestApplication) => { // Parse cookies app.use(cookieParser()); + app.useGlobalPipes( + new I18nValidationPipe({ + transform: true, + }), + ); + + // app.useGlobalFilters(new I18nValidationExceptionFilter()); + app.useGlobalFilters(new HttpExceptionFilter()); + // Enable CORS app.enableCors({ origin: [ diff --git a/src/@core/decorators/cursor.decorator.ts b/src/@core/decorators/cursor.decorator.ts new file mode 100644 index 0000000..f5f89bc --- /dev/null +++ b/src/@core/decorators/cursor.decorator.ts @@ -0,0 +1,65 @@ +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; + +export const CursorParams = createParamDecorator( + (options: ICursorParams, ctx: ExecutionContextHost): ICursor => { + const req = ctx.switchToHttp().getRequest() as Request; + + const cursorId = req.query?.cursorId ?? null; + const limit = + req.query?.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..8a2750d --- /dev/null +++ b/src/@core/decorators/filter.decorator.ts @@ -0,0 +1,95 @@ +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[]; +} + +export const FilterParams = createParamDecorator( + (options: any, ctx: ExecutionContextHost): IFilters => { + const req = ctx.switchToHttp().getRequest() as Request; + const rawFilters = req.query?.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..d2ae4af 100644 --- a/src/@core/decorators/pagination.decorator.ts +++ b/src/@core/decorators/pagination.decorator.ts @@ -23,12 +23,8 @@ export interface IPagination { 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; @@ -36,17 +32,15 @@ export const PaginationParams = createParamDecorator( const size = Number(req.query?.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..b94e113 --- /dev/null +++ b/src/@core/decorators/search.decorator.ts @@ -0,0 +1,31 @@ +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"; + +const SearchParam = createParamDecorator( + (options: any, ctx: ExecutionContextHost): string | null => { + const req = ctx.switchToHttp().getRequest() as Request; + const search = req.query?.search ?? null; + + if (typeof search === "string" && search.length > 0) { + return search; + } + + return null; + }, + [ + addMetadata([ + { + in: "query", + name: "search", + type: "string", + description: "Search query string", + required: false, + example: "pizza", + }, + ]), + ], +); + +export default SearchParam; diff --git a/src/@core/decorators/sorting.decorator.ts b/src/@core/decorators/sorting.decorator.ts index 9a2f934..664755a 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"; @@ -33,40 +34,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..a519031 --- /dev/null +++ b/src/@core/env.ts @@ -0,0 +1,89 @@ +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), + + // 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(), +}); + +const env = envSchema.parse({ + NODE_ENV: process.env.NODE_ENV, + PORT: process.env.PORT, + 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, +}); + +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..30a172d --- /dev/null +++ b/src/@core/errors/http-exception-filter.ts @@ -0,0 +1,89 @@ +// 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 { 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() as Request; + const response = 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; + + response.status(statusCode).json({ + 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..b9a6458 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 { IRestaurant } from "@postgress-db/schema/restaurants"; +import { ISession } from "@postgress-db/schema/sessions"; +import { IWorker, IWorkersToRestaurants } from "@postgress-db/schema/workers"; import { Request as Req } from "express"; +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 Req { - worker?: Omit & { passwordHash: undefined }; - session?: ISession; + requestId?: string; + timestamp?: number; + worker?: RequestWorker | null; + session?: RequestSession | null; + user?: { + id: string; + [key: string]: any; + }; } 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/validation.pipe.ts b/src/@core/pipes/validation.pipe.ts index 13dd967..e482146 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"; @@ -38,10 +37,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 +54,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..a02eef6 --- /dev/null +++ b/src/@socket/socket.gateway.ts @@ -0,0 +1,467 @@ +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 { + ClientSubscriptionType, + GatewayClient, + GatewayClients, + GatewayClientSubscription, + GatewayIncomingMessage, + GatewayMessage, + GatewayWorker, + IncomingSubscription, +} 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 localClientsSocketMap: Map = new Map(); + private localWorkersMap: Map = new Map(); + private localSubscriptionsMap: 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}:subscriptions`, + this.REDIS_CLIENTS_TTL, + JSON.stringify( + Object.fromEntries(this.localSubscriptionsMap.entries()), + ), + ); + + 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 getSubscriptions(): Promise { + const gatewayKeys = await this.publisherRedis.keys( + `${SocketUtils.commonGatewaysIdentifier}:*:subscriptions`, + ); + + const subscriptionsRaw = await this.publisherRedis.mget(gatewayKeys); + + const subscriptions = subscriptionsRaw + .flatMap((raw) => + Object.values( + JSON.parse(String(raw)) as Record< + string, + GatewayClientSubscription[] + >, + ), + ) + .flat(); + + return subscriptions; + } + + 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); + + socket.emit("connected", worker); + } 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); + this.localSubscriptionsMap.delete(socket.id); + } catch (error) { + this.logger.error(error); + socket.disconnect(true); + } + } + + /** + * Handle a subscription + * @param incomingData - The incoming data + * @param socket - The socket connection + */ + @SubscribeMessage(GatewayIncomingMessage.SUBSCRIPTION) + async handleSubscription( + @MessageBody() incomingData: IncomingSubscription, + @ConnectedSocket() socket: Socket, + ) { + const clientId = socket.id; + + const { id, type, data, action } = incomingData; + + try { + let subscriptions = this.localSubscriptionsMap.get(clientId) ?? []; + + switch (type) { + case ClientSubscriptionType.MULTIPLE_ORDERS: { + if (action === "subscribe") { + subscriptions.push({ + id, + clientId, + type, + data: { + orderIds: data.orderIds, + }, + } satisfies GatewayClientSubscription); + } else if (action === "unsubscribe") { + subscriptions = subscriptions.filter( + (subscription) => subscription.id !== id, + ); + } + + break; + } + case ClientSubscriptionType.ORDER: { + if (action === "subscribe") { + subscriptions.push({ + id, + clientId, + type, + data: { + orderId: data.orderId, + }, + } satisfies GatewayClientSubscription); + } else if (action === "unsubscribe") { + subscriptions = subscriptions.filter( + (subscription) => subscription.id !== id, + ); + } + break; + } + + default: { + throw new Error("Invalid subscription type"); + } + } + + this.localSubscriptionsMap.set(clientId, subscriptions); + + socket.emit("subscription", { + id, + action, + success: true, + }); + } catch (error) { + this.logger.error(error); + + socket.emit("subscription", { + id, + action, + success: false, + }); + } + } +} 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..aeaa688 --- /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 getSubscriptions() { + return await this.socketGateway.getSubscriptions(); + } + + 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..c69a193 --- /dev/null +++ b/src/@socket/socket.types.ts @@ -0,0 +1,130 @@ +import { IWorker, IWorkersToRestaurants } from "@postgress-db/schema/workers"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +export enum GatewayIncomingMessage { + SUBSCRIPTION = "subscription", +} + +export enum ClientSubscriptionType { + ORDER = "ORDER", + MULTIPLE_ORDERS = "MULTIPLE_ORDERS", + ORDER_DISHES = "ORDER_DISHES", + MULTIPLE_ORDER_DISHES = "MULTIPLE_ORDER_DISHES", +} + +export interface ClientOrderSubscription { + orderId: string; +} + +export interface ClientMultipleOrdersSubscription { + orderIds: string[]; +} + +export interface ClientOrderDishesSubscription { + orderId: string; +} + +export interface ClientMultipleOrderDishesSubscription { + orderIds: string[]; +} + +export type GatewayClientSubscription = + | { + id: string; + clientId: string; + type: ClientSubscriptionType.ORDER; + data: ClientOrderSubscription; + } + | { + id: string; + clientId: string; + type: ClientSubscriptionType.MULTIPLE_ORDERS; + data: ClientMultipleOrdersSubscription; + } + | { + id: string; + clientId: string; + type: ClientSubscriptionType.ORDER_DISHES; + data: ClientOrderDishesSubscription; + } + | { + id: string; + clientId: string; + type: ClientSubscriptionType.MULTIPLE_ORDER_DISHES; + data: ClientMultipleOrderDishesSubscription; + }; + +export enum IncomingSubscriptionAction { + SUBSCRIBE = "subscribe", + UNSUBSCRIBE = "unsubscribe", +} + +export type IncomingSubscription = { + action: `${IncomingSubscriptionAction}`; +} & ( + | { + id: string; + type: ClientSubscriptionType.ORDER; + data: ClientOrderSubscription; + } + | { + id: string; + type: ClientSubscriptionType.MULTIPLE_ORDERS; + data: ClientMultipleOrdersSubscription; + } + | { + id: string; + type: ClientSubscriptionType.ORDER_DISHES; + data: ClientOrderDishesSubscription; + } + | { + id: string; + type: ClientSubscriptionType.MULTIPLE_ORDER_DISHES; + data: ClientMultipleOrderDishesSubscription; + } +); + +export interface GatewayClient { + 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 { + SUBSCRIPTION_UPDATE = "subscription:update", +} + +export type SocketOrderUpdateEvent = { + id: string; + type: "ORDER"; + order: OrderEntity; +}; + +export interface SocketEvent { + type: `${SocketEventType}`; + data: SocketOrderUpdateEvent; +} 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..cc12d17 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,17 +1,46 @@ -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 { 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 +55,8 @@ import { WorkersModule } from "./workers/workers.module"; ], }), DrizzleModule, + SnapshotsModule, + AuditLogsModule, MongooseModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -33,16 +64,50 @@ 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, + 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 +116,13 @@ import { WorkersModule } from "./workers/workers.module"; provide: APP_GUARD, useClass: SessionAuthGuard, }, + { + provide: APP_INTERCEPTOR, + useClass: AuditLogsInterceptor, + }, { provide: APP_GUARD, - useClass: RolesGuard, + useClass: RestaurantGuard, }, ], }) 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..27288f3 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,32 @@ 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.cookie(AUTH_COOKIES.token, signedJWT, { + maxAge: 60 * 60 * 24 * 365, // 1 year + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + }); + return { ...worker, - setSessionToken: session.token, }; } - @RequireSessionAuth() - @SetCookies() @Delete("sign-out") @HttpCode(HttpStatus.OK) @Serializable(class Empty {}) @@ -100,7 +113,8 @@ export class AuthController { description: "You unauthorized", }) async signOut(@Cookies(AUTH_COOKIES.token) token: string) { - await this.authService.destroySession(token); + token; + // await this.authService.destroySession(token); return { setSessionToken: null, 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..0f8ed5b 100644 --- a/src/auth/guards/session-auth.guard.ts +++ b/src/auth/guards/session-auth.guard.ts @@ -1,91 +1,130 @@ +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.cookie(AUTH_COOKIES.token, newSignedJWT, { + maxAge: 60 * 60 * 24 * 365, // 1 year + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + }); + } 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..72e7c48 --- /dev/null +++ b/src/auth/services/sessions.service.ts @@ -0,0 +1,264 @@ +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; + } + + return session; + } +} diff --git a/src/discounts/discounts.controller.ts b/src/discounts/discounts.controller.ts new file mode 100644 index 0000000..9c5da80 --- /dev/null +++ b/src/discounts/discounts.controller.ts @@ -0,0 +1,86 @@ +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 } 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") + @ApiOperation({ + summary: "Get a discount by id", + }) + 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..7cc9d32 --- /dev/null +++ b/src/discounts/dto/create-discount.dto.ts @@ -0,0 +1,33 @@ +import { IsArray, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +import { DiscountEntity } from "../entities/discount.entity"; + +export class CreateDiscountDto extends PickType(DiscountEntity, [ + "name", + "description", + "percent", + "orderFroms", + "orderTypes", + "daysOfWeek", + "promocode", + "applyForFirstOrder", + "applyByPromocode", + "applyByDefault", + "isEnabled", + "startHour", + "endHour", + "activeFrom", + "activeTo", +]) { + @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[]; +} 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..29b67f1 --- /dev/null +++ b/src/discounts/entities/discount.entity.ts @@ -0,0 +1,208 @@ +import { + IsArray, + IsBoolean, + IsDate, + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + IsUUID, + Max, + Min, + MinLength, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IDiscount } from "@postgress-db/schema/discounts"; +import { ZodDayOfWeekEnum } from "@postgress-db/schema/general"; +import { + ZodOrderFromEnum, + ZodOrderTypeEnum, +} from "@postgress-db/schema/orders"; +import { Expose, Type } from "class-transformer"; + +export class DiscountRestaurantEntity { + @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 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, + }) + applyForFirstOrder: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether discount is applied by promocode", + example: true, + }) + applyByPromocode: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether discount is applied by default", + example: true, + }) + applyByDefault: boolean; + + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Whether discount is enabled", + example: true, + }) + isEnabled: boolean; + + @IsOptional() + @IsNumber() + @Expose() + @ApiPropertyOptional({ + description: "Start hour of the discount (0-23)", + example: 14, + }) + startHour: number | null; + + @IsOptional() + @IsNumber() + @Expose() + @ApiPropertyOptional({ + description: "End hour of the discount (0-23)", + example: 18, + }) + endHour: number | 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; + + @IsArray() + @Expose() + @Type(() => DiscountRestaurantEntity) + @ApiProperty({ + description: "Restaurants where discount is applicable", + type: [DiscountRestaurantEntity], + }) + restaurants: DiscountRestaurantEntity[]; + + @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; +} diff --git a/src/discounts/services/discounts.service.ts b/src/discounts/services/discounts.service.ts new file mode 100644 index 0000000..d53af2a --- /dev/null +++ b/src/discounts/services/discounts.service.ts @@ -0,0 +1,227 @@ +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, + discountsToRestaurants, +} 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 { DiscountEntity } 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: discountsToRestaurants.restaurantId }) + .from(discountsToRestaurants) + .where(inArray(discountsToRestaurants.restaurantId, restaurantIds)), + ), + ); + } + + const fetchedDiscounts = await this.pg.query.discounts.findMany({ + ...(conditions.length > 0 ? { where: () => and(...conditions) } : {}), + with: { + discountsToRestaurants: { + with: { + restaurant: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + orderBy: (discounts, { desc }) => [desc(discounts.createdAt)], + }); + + return fetchedDiscounts.map(({ discountsToRestaurants, ...discount }) => ({ + ...discount, + restaurants: discountsToRestaurants.map(({ restaurant }) => ({ + restaurantId: restaurant.id, + restaurantName: restaurant.name, + })), + })); + } + + public async findOne(id: string, options: { worker?: RequestWorker }) { + const { worker } = options; + + worker; + + const discount = await this.pg.query.discounts.findFirst({ + where: eq(discounts.id, id), + with: { + discountsToRestaurants: { + with: { + restaurant: true, + }, + }, + }, + }); + + if (!discount) { + return null; + } + + return { + ...discount, + restaurants: discount.discountsToRestaurants.map(({ restaurant }) => ({ + restaurantId: restaurant.id, + restaurantName: restaurant.name, + })), + }; + } + + private async validatePayload( + payload: CreateDiscountDto | UpdateDiscountDto, + worker: RequestWorker, + ) { + if (!payload.restaurantIds || payload.restaurantIds.length === 0) { + throw new BadRequestException( + "errors.discounts.you-should-provide-at-least-one-restaurant-id", + { + property: "restaurantIds", + }, + ); + } + + // 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), + ); + + if (payload.restaurantIds.some((id) => !restaurantIdsSet.has(id))) { + throw new BadRequestException( + "errors.discounts.you-provided-restaurant-id-that-you-dont-own", + { + property: "restaurantIds", + }, + ); + } + } + } + + 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, + }); + + await tx.insert(discountsToRestaurants).values( + payload.restaurantIds.map((id) => ({ + discountId: discount.id, + restaurantId: id, + })), + ); + + 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.restaurantIds) { + 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 restaurantIds are provided, update restaurant associations + if (payload.restaurantIds) { + // Delete existing associations + await tx + .delete(discountsToRestaurants) + .where(eq(discountsToRestaurants.discountId, id)); + + // Create new associations + await tx.insert(discountsToRestaurants).values( + payload.restaurantIds.map((restaurantId) => ({ + discountId: id, + restaurantId, + })), + ); + } + + 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..986decd --- /dev/null +++ b/src/discounts/services/order-discounts.service.ts @@ -0,0 +1,25 @@ +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, + }, + }); + + order; + } +} 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..5f539d4 --- /dev/null +++ b/src/dishes-menus/dishes-menus.service.ts @@ -0,0 +1,426 @@ +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 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( + inArray( + dishesMenus.ownerId, + worker.workersToRestaurants.map(({ restaurantId }) => restaurantId), + ), + ); + } + + const fetchedMenus = await this.pg.query.dishesMenus.findMany({ + ...(conditions.length > 0 && { where: 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..3f9257c --- /dev/null +++ b/src/dishes/@/dishes.controller.ts @@ -0,0 +1,238 @@ +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 SearchParam 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 { 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, + }) + async findMany( + @SortingParams({ + fields: [ + "id", + "name", + "cookingTimeInMin", + "weight", + "updatedAt", + "createdAt", + ], + }) + sorting: ISorting, + @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, + @SearchParam() search?: string, + @Query("menuId") + menuId?: string, + ): 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 && typeof menuId === "string" && menuId !== "undefined") { + if (!filters) { + filters = { filters: [] }; + } + + filters.filters.push({ + field: "menuId", + value: menuId, + condition: FilterCondition.Equals, + }); + } + + const total = await this.dishesService.getTotalCount({ + filters, + }); + + const data = await this.dishesService.findMany({ + pagination, + sorting, + filters, + }); + + 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..1ecad38 --- /dev/null +++ b/src/dishes/@/dishes.service.ts @@ -0,0 +1,255 @@ +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 { and, asc, count, desc, eq, 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(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + public async getTotalCount({ + filters, + }: { + filters?: IFilters; + }): 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 (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; + }): Promise { + const { pagination, sorting, filters } = options ?? {}; + + const where = filters + ? DrizzleUtils.buildFilterConditions(schema.dishes, filters) + : undefined; + + 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, + with: { + dishesToImages: { + with: { + imageFile: true, + }, + }, + }, + orderBy, + limit: pagination?.size ?? PAGINATION_DEFAULT_LIMIT, + offset: pagination?.offset ?? 0, + }); + + return result.map((dish) => ({ + ...dish, + 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; + } + + public async create( + dto: CreateDishDto, + options: { worker: RequestWorker }, + ): Promise { + const { worker } = options; + + // Validate menu id + await this.validateMenuId(dto.menuId, worker); + + const [dish] = await this.pg + .insert(schema.dishes) + .values({ + ...dto, + }) + .returning(); + + if (!dish) { + throw new ServerErrorException("Failed to create dish"); + } + + return { ...dish, images: [] }; + } + + public async update( + id: string, + payload: UpdateDishDto, + options: { worker: RequestWorker }, + ): Promise { + 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 (Object.keys(payload).length === 0) { + throw new BadRequestException( + "errors.dishes.you-should-provide-at-least-one-field-to-update", + ); + } + + await this.pg + .update(schema.dishes) + .set(payload) + .where(eq(schema.dishes.id, id)); + + 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: { + 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, + })), + }; + } +} diff --git a/src/dishes/@/dtos/create-dish.dto.ts b/src/dishes/@/dtos/create-dish.dto.ts new file mode 100644 index 0000000..a119088 --- /dev/null +++ b/src/dishes/@/dtos/create-dish.dto.ts @@ -0,0 +1,32 @@ +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; +} 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..1c27e73 --- /dev/null +++ b/src/dishes/@/entities/dish.entity.ts @@ -0,0 +1,141 @@ +import { + IsBoolean, + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IDish, ZodWeightMeasureEnum } from "@postgress-db/schema/dishes"; +import { Expose } from "class-transformer"; +import { DishImageEntity } from "src/dishes/@/entities/dish-image.entity"; + +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() + @ApiProperty({ + description: "Images associated with the dish", + type: [DishImageEntity], + }) + images: DishImageEntity[]; + + @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..ca4b260 --- /dev/null +++ b/src/dishes/pricelist/dish-pricelist.service.ts @@ -0,0 +1,194 @@ +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, + }, + }, + }, + }, + }, + }); + + if ( + !dish || + !dish.menu || + dish.menu.dishesMenusToRestaurants.length === 0 + ) { + throw new BadRequestException( + "errors.dish-pricelist.provided-restaurant-dont-assigned-to-menu", + ); + } + + // 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/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..847c271 --- /dev/null +++ b/src/guests/guests.controller.ts @@ -0,0 +1,158 @@ +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 { 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({ 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..dd3b3dd --- /dev/null +++ b/src/guests/guests.service.ts @@ -0,0 +1,165 @@ +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 { 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, + }) + .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) + .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]; + } +} diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json new file mode 100644 index 0000000..e3da2b4 --- /dev/null +++ b/src/i18n/messages/en/errors.json @@ -0,0 +1,139 @@ +{ + "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": { + "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" + }, + "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" + }, + "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-provided-restaurant-id-that-you-dont-own": "You provided restaurant id that you don't own" + }, + "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" + } +} diff --git a/src/i18n/messages/en/validation.json b/src/i18n/messages/en/validation.json new file mode 100644 index 0000000..561cfb2 --- /dev/null +++ b/src/i18n/messages/en/validation.json @@ -0,0 +1,24 @@ +{ + "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" + } +} 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..fd6e825 --- /dev/null +++ b/src/i18n/messages/ru/errors.json @@ -0,0 +1,80 @@ +{ + "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": "Блюдо уже удалено" + } +} 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..a131d07 --- /dev/null +++ b/src/i18n/validators/index.ts @@ -0,0 +1,162 @@ +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, + Max as _Max, + 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 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))); diff --git a/src/main.ts b/src/main.ts index 1277f86..74be3f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,31 +1,13 @@ +import "dotenv/config"; import { configApp } from "@core/config/app"; +import env from "@core/env"; import { NestFactory } from "@nestjs/core"; 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 { 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 }, - ); - - 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, - }); - } -}; - async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -42,6 +24,7 @@ async function bootstrap() { .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); @@ -53,9 +36,8 @@ async function bootstrap() { swaggerOptions: {}, }); - await app.listen(3000); + await app.listen(env.PORT); } -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-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-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..715d58b --- /dev/null +++ b/src/orders/@/entities/order.entity.ts @@ -0,0 +1,285 @@ +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 { + IOrder, + ZodOrderFromEnum, + ZodOrderStatusEnum, + ZodOrderTypeEnum, +} 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() + @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() + @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..128e1de --- /dev/null +++ b/src/orders/@/order-actions.controller.ts @@ -0,0 +1,44 @@ +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 { OrderActionsService } from "src/orders/@/services/order-actions.service"; +import { OrdersService } from "src/orders/@/services/orders.service"; + +@Controller("orders/:id/actions", { + tags: ["orders"], +}) +export class OrderActionsController { + constructor( + private readonly ordersService: OrdersService, + 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, + }); + } +} 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-menu.controller.ts b/src/orders/@/order-menu.controller.ts new file mode 100644 index 0000000..be84abf --- /dev/null +++ b/src/orders/@/order-menu.controller.ts @@ -0,0 +1,46 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { CursorParams, ICursor } from "@core/decorators/cursor.decorator"; +import SearchParam from "@core/decorators/search.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 { 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(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, + }) + async getDishes( + @Param("orderId") orderId: string, + @CursorParams() cursor: ICursor, + @SearchParam() search?: string, + ): Promise { + const data = await this.orderMenuService.getDishes(orderId, { + cursor, + search, + }); + + 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..c088618 --- /dev/null +++ b/src/orders/@/repositories/order-dishes.repository.ts @@ -0,0 +1,120 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { DrizzleTransaction, Schema } from "@postgress-db/drizzle.module"; +import { 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 { OrderPricesService } from "src/orders/@/services/order-prices.service"; + +@Injectable() +export class OrderDishesRepository { + constructor( + // Postgres connection + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + // Services + private readonly orderPricesService: OrderPricesService, + 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, + }); + } + + /** + * 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) => { + // Insert new data to database + const [orderDish] = await tx + .insert(orderDishes) + .values(payload) + .returning(); + + // Calculate order totals price + await this.orderPricesService.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: Partial, + opts?: { + tx?: DrizzleTransaction; + workerId?: string; + }, + ) { + const tx = opts?.tx ?? this.pg; + + const result = await tx.transaction(async (tx) => { + // Update order dish + const [orderDish] = await tx + .update(orderDishes) + .set(payload) + .where(eq(orderDishes.id, orderDishId)) + .returning(); + + // Calculate order totals price + await this.orderPricesService.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; + } +} diff --git a/src/orders/@/repositories/orders.repository.ts b/src/orders/@/repositories/orders.repository.ts new file mode 100644 index 0000000..fa2abf3 --- /dev/null +++ b/src/orders/@/repositories/orders.repository.ts @@ -0,0 +1,105 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { DrizzleTransaction, Schema } from "@postgress-db/drizzle.module"; +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 { OrderSnapshotEntity } from "src/orders/@/entities/order-snapshot.entity"; + +@Injectable() +export class OrdersRepository { + constructor( + // Postgres connection + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + // Services + private readonly snapshotsProducer: SnapshotsProducer, + ) {} + + /** + * 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(); + + 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; + }); + + // 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..69a1e3f --- /dev/null +++ b/src/orders/@/services/order-actions.service.ts @@ -0,0 +1,254 @@ +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 { + orderDishes, + orderDishesReturnments, +} from "@postgress-db/schema/order-dishes"; +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 { 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 === "completed") + ) { + 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, + }, + ); + }); + } + + 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: { + 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, + }); + }); + } +} diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts new file mode 100644 index 0000000..38566e9 --- /dev/null +++ b/src/orders/@/services/order-dishes.service.ts @@ -0,0 +1,322 @@ +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.dishCrudUpdate({ + action: "CREATE", + orderDishId: orderDish.id, + orderDish, + calledByWorkerId: opts?.workerId, + }); + + 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.dishCrudUpdate({ + action: "UPDATE", + orderDishId: orderDish.id, + orderDish: updatedOrderDish, + calledByWorkerId: opts?.workerId, + }); + + 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.dishCrudUpdate({ + action: "DELETE", + orderDishId: orderDish.id, + orderDish: removedOrderDish, + calledByWorkerId: opts?.workerId, + }); + + 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-menu.service.ts b/src/orders/@/services/order-menu.service.ts new file mode 100644 index 0000000..d42baf3 --- /dev/null +++ b/src/orders/@/services/order-menu.service.ts @@ -0,0 +1,207 @@ +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 { dishes, dishesToRestaurants } from "@postgress-db/schema/dishes"; +import { asc } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +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 getDishes( + orderId: string, + opts?: { + cursor: ICursor; + search?: string; + }, + ): Promise { + const { cursor, search } = 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 }) => + and( + // insensetive search + search && search !== "null" + ? ilike(dishes.name, `%${search}%`) + : undefined, + // 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), + ), + ), + ), + ), + 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-prices.service.ts b/src/orders/@/services/order-prices.service.ts new file mode 100644 index 0000000..bf393c1 --- /dev/null +++ b/src/orders/@/services/order-prices.service.ts @@ -0,0 +1,70 @@ +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 OrderPricesService { + private readonly logger = new Logger(OrderPricesService.name); + + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly repository: OrdersRepository, + ) {} + + 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, + }, + }); + + if (!orderDishes.length) { + this.logger.warn(`No dishes found for order ${orderId}`); + return; + } + + 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..28bc8fe --- /dev/null +++ b/src/orders/@/services/orders.service.ts @@ -0,0 +1,393 @@ +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 { 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 { OrderDishModifierEntity } from "src/orders/@/entities/order-dish-modifier.entity"; +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 createdOrder = await this.repository.create( + { + number, + tableNumber, + type, + from: "internal", + status: "pending", + currency: "RUB", + 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); + + await this.ordersQueueProducer.crudUpdate({ + action: "CREATE", + orderId: createdOrder.id, + order, + calledByWorkerId: opts?.workerId, + }); + + 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); + + await this.ordersQueueProducer.crudUpdate({ + action: "UPDATE", + orderId: id, + order: updatedOrderEntity, + calledByWorkerId: opts?.workerId, + }); + + 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 attachRestaurantsName< + T extends { restaurant?: { name?: string | null } | null }, + >(orders: Array): Array { + return orders.map((order) => ({ + ...order, + restaurantName: order.restaurant?.name ?? null, + })); + } + + 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[], + })), + })); + } + + public async findById(id: string): Promise { + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, id), + with: { + restaurant: { + columns: { + name: true, + }, + }, + orderDishes: { + with: { + dishModifiersToOrderDishes: { + with: { + dishModifier: { + columns: { + name: true, + }, + }, + }, + columns: { + dishModifierId: true, + }, + }, + }, + }, + }, + }); + + if (!order) { + throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); + } + + const withRestaurantsName = this.attachRestaurantsName([order])[0]; + const withModifiers = this.attachModifiers([withRestaurantsName])[0]; + + return withModifiers; + } +} 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..429085c --- /dev/null +++ b/src/orders/@queue/dto/crud-update.job.ts @@ -0,0 +1,17 @@ +import { CrudAction } from "@core/types/general"; +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +export class OrderCrudUpdateJobDto { + orderId: string; + order: OrderEntity; + action: `${CrudAction}`; + calledByWorkerId?: string; +} + +export class OrderDishCrudUpdateJobDto { + orderDishId: string; + orderDish: Omit; + action: `${CrudAction}`; + calledByWorkerId?: string; +} diff --git a/src/orders/@queue/dto/recalculate-prices-job.dto.ts b/src/orders/@queue/dto/recalculate-prices-job.dto.ts new file mode 100644 index 0000000..0d1353e --- /dev/null +++ b/src/orders/@queue/dto/recalculate-prices-job.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from "@i18n-class-validator"; + +export class RecalculatePricesJobDto { + @IsString() + orderId: string; +} diff --git a/src/orders/@queue/index.ts b/src/orders/@queue/index.ts new file mode 100644 index 0000000..58adab4 --- /dev/null +++ b/src/orders/@queue/index.ts @@ -0,0 +1,7 @@ +export const ORDERS_QUEUE = "orders"; + +export enum OrderQueueJobName { + CRUD_UPDATE = "crud-update", + DISH_CRUD_UPDATE = "dish-crud-update", + RECALCULATE_PRICES = "recalculate-prices", +} 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..54d271b --- /dev/null +++ b/src/orders/@queue/orders-queue.processor.ts @@ -0,0 +1,80 @@ +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 { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; +import { + OrderCrudUpdateJobDto, + OrderDishCrudUpdateJobDto, +} from "src/orders/@queue/dto/crud-update.job"; +import { RecalculatePricesJobDto } from "src/orders/@queue/dto/recalculate-prices-job.dto"; +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, + ) { + super(); + } + + /** + * Just a bullmq processor + */ + async process(job: Job) { + const { name, data } = job; + + try { + switch (name) { + // Recalculate prices of the order + case OrderQueueJobName.RECALCULATE_PRICES: { + await this.recalculatePrices(data as RecalculatePricesJobDto); + break; + } + + case OrderQueueJobName.CRUD_UPDATE: { + await this.crudUpdate(data as OrderCrudUpdateJobDto); + break; + } + + case OrderQueueJobName.DISH_CRUD_UPDATE: { + await this.dishCrudUpdate(data as OrderDishCrudUpdateJobDto); + break; + } + + default: { + throw new Error(`Unknown job name`); + } + } + } catch (error) { + this.logger.error(`Failed to process ${name} job`, error); + + throw error; + } + } + + /** + * Recalculates the prices of the order + * @param data + */ + private async recalculatePrices(data: RecalculatePricesJobDto) { + const { orderId } = data; + orderId; + } + + private async crudUpdate(data: OrderCrudUpdateJobDto) { + // notify users + await this.ordersSocketNotifier.handle(data.order); + } + + private async dishCrudUpdate(data: OrderDishCrudUpdateJobDto) { + // notify users + await this.ordersSocketNotifier.handleById(data.orderDish.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..6d0f33c --- /dev/null +++ b/src/orders/@queue/orders-queue.producer.ts @@ -0,0 +1,53 @@ +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"; +import { RecalculatePricesJobDto } from "src/orders/@queue/dto/recalculate-prices-job.dto"; + +@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 crudUpdate(payload: OrderCrudUpdateJobDto) { + return this.addJob(OrderQueueJobName.CRUD_UPDATE, payload); + } + + /** + * When order dish is: created, updated, removed + */ + public async dishCrudUpdate(payload: OrderDishCrudUpdateJobDto) { + return this.addJob(OrderQueueJobName.DISH_CRUD_UPDATE, payload); + } + + /** + * This producer creates a job that recalculates prices of the order based on the order dishes + * @param orderId ID of the order that needs to be recalculated + * @returns Job + */ + public async recalculatePrices(orderId: string) { + return this.addJob(OrderQueueJobName.RECALCULATE_PRICES, { + orderId, + } satisfies RecalculatePricesJobDto); + } +} 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..3bf2dde --- /dev/null +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -0,0 +1,78 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; +import { SocketService } from "src/@socket/socket.service"; +import { + ClientSubscriptionType, + GatewayClient, + SocketEventType, + SocketOrderUpdateEvent, +} from "src/@socket/socket.types"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { OrdersService } from "src/orders/@/services/orders.service"; + +@Injectable() +export class OrdersSocketNotifier { + private readonly logger = new Logger(OrdersSocketNotifier.name); + + constructor( + private readonly socketService: SocketService, + private readonly ordersService: OrdersService, + ) {} + + public async handleById(orderId: string) { + const order = await this.ordersService.findById(orderId); + + if (!order) { + this.logger.error(`Order with id ${orderId} not found`); + return; + } + + await this.handle(order); + } + + /** + * ! WE SHOULD NOTIFY USERS ONLY IF ORDER HAVE CHANGED DATA + * ! (needs to be implemented before calling that method) + * @param order + */ + public async handle(order: OrderEntity) { + const clients = await this.socketService.getClients(); + const subscriptions = await this.socketService.getSubscriptions(); + + const clientsMap = new Map( + clients.map((client) => [client.clientId, client]), + ); + + const messages: { + recipient: GatewayClient; + event: string; + data: any; + }[] = []; + + subscriptions.forEach((subscription) => { + if ( + (subscription.type === ClientSubscriptionType.ORDER && + subscription.data.orderId === order.id) || + (subscription.type === ClientSubscriptionType.MULTIPLE_ORDERS && + subscription.data.orderIds.includes(order.id)) + ) { + const recipient = clientsMap.get(subscription.clientId); + if (!recipient) return; + + messages.push({ + recipient, + event: SocketEventType.SUBSCRIPTION_UPDATE, + data: { + id: subscription.id, + type: "ORDER", + order: plainToClass(OrderEntity, order, { + excludeExtraneousValues: true, + }), + } satisfies SocketOrderUpdateEvent, + }); + } + }); + + 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..fa0d0d0 --- /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/orders"; +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..593bf01 --- /dev/null +++ b/src/orders/dispatcher/dispatcher-orders.service.ts @@ -0,0 +1,194 @@ +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/orders"; +import { addDays } from "date-fns"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { DispatcherOrderEntity } from "src/orders/dispatcher/entities/dispatcher-order.entity"; + +@Injectable() +export class DispatcherOrdersService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + private attachRestaurantsName< + T extends { restaurant?: { name?: string | null } | null }, + >(orders: Array): Array { + return orders.map((order) => ({ + ...order, + restaurantName: order.restaurant?.name ?? null, + })); + } + + 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.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, + // 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), + ), + ), + ), + ), + // Exclude archived orders + eq(orders.isArchived, false), + // Exclude cancelled and completed orders + notInArray(orders.status, ["cancelled", "completed"]), + // 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, { desc }) => [desc(orders.createdAt)], + limit: 100, + }); + + return this.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.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..99426ad --- /dev/null +++ b/src/orders/kitchener/kitchener-order-actions.service.ts @@ -0,0 +1,95 @@ +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.dishCrudUpdate({ + action: "UPDATE", + orderDishId: orderDish.id, + orderDish: updatedOrderDish, + calledByWorkerId: opts?.worker?.id, + }); + + 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..3de1fda --- /dev/null +++ b/src/orders/kitchener/kitchener-orders.controller.ts @@ -0,0 +1,59 @@ +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() + @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..9970b6d --- /dev/null +++ b/src/orders/kitchener/kitchener-orders.service.ts @@ -0,0 +1,267 @@ +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { orderDishes } from "@postgress-db/schema/order-dishes"; +import { 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 { 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; + } + + async findMany(opts: { + worker: RequestWorker; + }): Promise { + const { worker } = opts; + const workerId = worker.id; + const restaurantIds = worker.workersToRestaurants.map( + (wtr) => wtr.restaurantId, + ); + + 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: (orders, { eq, and, or, exists, inArray }) => { + 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), + ), + ), + ), + ]; + + // System admin and chief admin can see all orders + if (worker.role !== "SYSTEM_ADMIN" && worker.role !== "CHIEF_ADMIN") { + conditions.push(inArray(orders.restaurantId, restaurantIds)); + } + + return and(...conditions); + }, + with: { + orderDishes: { + // Filter + where: (orderDishes, { eq, and, gt, or }) => + and( + // Only not removed + eq(orderDishes.isRemoved, false), + // Only dishes with quantity > 0 + gt(orderDishes.quantity, 0), + // Only dishes with cooking or ready status + or( + eq(orderDishes.status, "cooking"), + eq(orderDishes.status, "ready"), + ), + ), + // 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: { + 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 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, + ); + } +} diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts new file mode 100644 index 0000000..f14f79a --- /dev/null +++ b/src/orders/orders.module.ts @@ -0,0 +1,52 @@ +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 { 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 { OrderDishesService } from "src/orders/@/services/order-dishes.service"; +import { OrderMenuService } from "src/orders/@/services/order-menu.service"; +import { OrderPricesService } from "src/orders/@/services/order-prices.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"; + +@Module({ + imports: [DrizzleModule, GuestsModule, OrdersQueueModule, SnapshotsModule], + providers: [ + OrdersRepository, + OrderDishesRepository, + OrdersService, + DispatcherOrdersService, + KitchenerOrdersService, + OrderDishesService, + OrderMenuService, + OrderPricesService, + OrderActionsService, + KitchenerOrderActionsService, + ], + controllers: [ + OrdersController, + OrderMenuController, + OrderDishesController, + DispatcherOrdersController, + KitchenerOrdersController, + OrderActionsController, + ], + 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..b944721 --- /dev/null +++ b/src/payment-methods/payment-methods.controller.ts @@ -0,0 +1,88 @@ +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"; + +@Controller("restaurants/:id/payment-methods", { + tags: ["restaurants"], +}) +export class PaymentMethodsController { + constructor(private readonly paymentMethodsService: PaymentMethodsService) {} + + @RestaurantGuard({ + restaurantId: (req) => req.params.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.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.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 similarity index 52% rename from src/restaurants/controllers/restaurants.controller.ts rename to src/restaurants/@/controllers/restaurants.controller.ts index 66fc18a..bea3d63 100644 --- a/src/restaurants/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -3,9 +3,11 @@ 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 { Worker } from "@core/decorators/worker.decorator"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; +import { RequestWorker } from "@core/interfaces/request"; +import { Body, Delete, Get, Param, Post, Put, Query } from "@nestjs/common"; import { ApiCreatedResponse, ApiForbiddenResponse, @@ -13,23 +15,26 @@ import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiQuery, ApiUnauthorizedResponse, } 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 { 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 { RestaurantEntity } from "../entities/restaurant.entity"; import { RestaurantsService } from "../services/restaurants.service"; -@RequireSessionAuth() @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", @@ -39,9 +44,31 @@ export class RestaurantsController { description: "Restaurants have been successfully fetched", type: RestaurantsPaginatedDto, }) - async findAll(@PaginationParams() pagination: IPagination) { + @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, + }) + async findAll( + @PaginationParams() pagination: IPagination, + @Worker() worker: RequestWorker, + @Query("menuId") menuId?: string, + @Query("ownerId") ownerId?: string, + ) { const total = await this.restaurantsService.getTotalCount(); - const data = await this.restaurantsService.findMany({ pagination }); + const data = await this.restaurantsService.findMany({ + pagination, + worker, + menuId, + ownerId, + }); return { data, @@ -52,48 +79,74 @@ export class RestaurantsController { }; } + @EnableAuditLog() @Post() - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") - @Serializable(RestaurantDto) + @Serializable(RestaurantEntity) @ApiOperation({ summary: "Creates a new restaurant", }) @ApiCreatedResponse({ description: "Restaurant has been successfully created", - type: RestaurantDto, + type: RestaurantEntity, }) @ApiForbiddenResponse({ - description: "Action available only for SYSTEM_ADMIN, CHIEF_ADMIN", - }) - async create(@Body() dto: CreateRestaurantDto): Promise { - return await this.restaurantsService.create(dto); + 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.id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) @Get(":id") - @Serializable(RestaurantDto) + @Serializable(RestaurantEntity) @ApiOperation({ summary: "Gets restaurant by id", }) @ApiOkResponse({ description: "Restaurant has been successfully fetched", - type: RestaurantDto, + type: RestaurantEntity, }) @ApiNotFoundResponse({ description: "Restaurant with this id not found", }) - async findOne(@Param("id") id: string): Promise { - return await this.restaurantsService.findById(id); + async findOne( + @Param("id") id: string, + @Worker() worker: RequestWorker, + ): Promise { + return await this.restaurantsService.findById(id, { + worker, + }); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) + @EnableAuditLog() @Put(":id") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") - @Serializable(RestaurantDto) + @Serializable(RestaurantEntity) @ApiOperation({ summary: "Updates restaurant by id", }) @ApiOkResponse({ description: "Restaurant has been successfully updated", - type: RestaurantDto, + type: RestaurantEntity, }) @ApiNotFoundResponse({ description: "Restaurant with this id not found", @@ -104,12 +157,19 @@ export class RestaurantsController { async update( @Param("id") id: string, @Body() dto: UpdateRestaurantDto, - ): Promise { - return await this.restaurantsService.update(id, dto); + @Worker() worker: RequestWorker, + ): Promise { + return await this.restaurantsService.update(id, dto, { + worker, + }); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER"], + }) + @EnableAuditLog() @Delete(":id") - @Roles("SYSTEM_ADMIN") @ApiOperation({ summary: "Deletes restaurant by 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..c3476e3 --- /dev/null +++ b/src/restaurants/@/dto/create-restaurant.dto.ts @@ -0,0 +1,15 @@ +import { PickType } from "@nestjs/swagger"; + +import { RestaurantEntity } from "../entities/restaurant.entity"; + +export class CreateRestaurantDto extends PickType(RestaurantEntity, [ + "name", + "legalEntity", + "address", + "latitude", + "longitude", + "timezone", + "isEnabled", + "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..bf0a9a5 --- /dev/null +++ b/src/restaurants/@/services/restaurants.service.ts @@ -0,0 +1,298 @@ +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, 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, + ) {} + + /** + * Gets total count of restaurants + * @returns + */ + // TODO: add menuId and ownerId filters + 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; + worker?: RequestWorker; + // TODO: replace with filters + menuId?: string; + ownerId?: string; + }): Promise { + const { pagination, worker, menuId, ownerId } = 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), + ), + ); + } + } + + // Filter restaurants that are assigned to a menu + if (menuId && menuId !== "undefined" && 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 !== "undefined" && ownerId.length > 0) { + conditions.push(eq(schema.restaurants.ownerId, ownerId)); + } + + 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, + // Only system admins and chief admins can update restaurant owner + ...(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/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..318a7a7 --- /dev/null +++ b/src/restaurants/dish-modifiers/restaurant-dish-modifiers.controller.ts @@ -0,0 +1,116 @@ +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, +) {} + +@Controller("restaurants/:id/dish-modifiers", { + tags: ["restaurants"], +}) +export class RestaurantDishModifiersController { + constructor( + private readonly restaurantDishModifiersService: RestaurantDishModifiersService, + ) {} + + @RestaurantGuard({ + restaurantId: (req) => req.params.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.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.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.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 71% rename from src/restaurants/controllers/restaurant-hours.controller.ts rename to src/restaurants/hours/restaurant-hours.controller.ts index a0f55d5..add8400 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,21 @@ 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() @Controller("restaurants/:id/hours", { tags: ["restaurants"], }) @@ -32,19 +31,28 @@ export class RestaurantHoursController { private readonly restaurantHoursService: RestaurantHoursService, ) {} + @RestaurantGuard({ + restaurantId: (req) => req.params.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.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 +70,13 @@ export class RestaurantHoursController { }); } + @RestaurantGuard({ + restaurantId: (req) => req.params.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 +91,12 @@ export class RestaurantHoursController { return await this.restaurantHoursService.update(id, dto); } + @RestaurantGuard({ + restaurantId: (req) => req.params.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..93c0e56 --- /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-payments"; +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..8ae1927 --- /dev/null +++ b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service.ts @@ -0,0 +1,139 @@ +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 } 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") { + 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(eq(workshiftPaymentCategories.id, 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..42ace92 --- /dev/null +++ b/src/restaurants/workshops/restaurant-workshops.controller.ts @@ -0,0 +1,154 @@ +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, +) {} + +@Controller("restaurants/:id/workshops", { + tags: ["restaurants"], +}) +export class RestaurantWorkshopsController { + constructor( + private readonly restaurantWorkshopsService: RestaurantWorkshopsService, + ) {} + + @RestaurantGuard({ + restaurantId: (req) => req.params.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.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.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.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.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.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..e35d877 --- /dev/null +++ b/src/timezones/timezones.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@nestjs/common"; +import { getTimeZones } from "@vvo/tzdb"; + +@Injectable() +export class TimezonesService { + getAllTimezones(): string[] { + const timezones = getTimeZones({ + includeUtc: true, + }).filter((tz) => tz.continentCode === "EU" || tz.countryCode === "RU"); + + return timezones.map((tz) => tz.name); + } + + checkTimezone(timezone: string): boolean { + const timezones = this.getAllTimezones(); + + return timezones.includes(timezone); + } +} 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..fe55aa3 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -1,17 +1,17 @@ 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 { Body, Get, Param, Post, Put, Query } from "@nestjs/common"; import { ApiBadRequestResponse, ApiConflictResponse, @@ -20,25 +20,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 +70,22 @@ export class WorkersController { }) sorting: ISorting, @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, + @Query("restaurantIds") restaurantIds?: string, ): Promise { - const total = await this.workersService.getTotalCount(); + const parsedRestaurantIds = + restaurantIds !== "undefined" ? 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 +98,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 +108,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 +148,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 +183,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..2c5dcd1 --- /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..04ccdff --- /dev/null +++ b/src/workshifts/entity/workshift.entity.ts @@ -0,0 +1,132 @@ +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", +]) {} + +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..ea38a91 --- /dev/null +++ b/src/workshifts/services/workshifts.service.ts @@ -0,0 +1,380 @@ +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, + }, + }, + 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, + }, + }, + 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..406882d --- /dev/null +++ b/src/workshifts/workshifts.controller.ts @@ -0,0 +1,139 @@ +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 { 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") _restaurantId?: string, + ): Promise { + const restaurantId = + _restaurantId && _restaurantId !== "null" && _restaurantId !== "undefined" + ? _restaurantId + : undefined; + + 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/workshifts.module.ts b/src/workshifts/workshifts.module.ts new file mode 100644 index 0000000..31c69ba --- /dev/null +++ b/src/workshifts/workshifts.module.ts @@ -0,0 +1,13 @@ +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"; + +@Module({ + imports: [DrizzleModule, SnapshotsModule], + controllers: [WorkshiftsController], + providers: [WorkshiftsService], + 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..eb5bc35 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/**/*"], "exclude": ["node_modules", "dist"], } diff --git a/yarn.lock b/yarn.lock index 10f2931..cf8a81b 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" @@ -653,6 +1253,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 +1526,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" @@ -960,6 +1572,61 @@ 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== + +"@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" @@ -1053,6 +1720,14 @@ multer "1.4.4-lts.1" tslib "2.6.2" +"@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" @@ -1089,6 +1764,15 @@ 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" @@ -1134,6 +1818,11 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== +"@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" @@ -1153,6 +1842,511 @@ 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: + "@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" + +"@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: + "@smithy/node-config-provider" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@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" + +"@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: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@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: + "@smithy/service-error-classification" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@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" + +"@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: + tslib "^2.6.2" + +"@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: + "@smithy/util-buffer-from" "^2.2.0" + tslib "^2.6.2" + +"@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" + +"@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" + +"@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== + +"@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== + +"@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" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" @@ -1233,6 +2427,13 @@ 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" @@ -1345,6 +2546,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 +2560,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 +2590,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 +2653,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 +2783,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" @@ -1685,7 +2924,12 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== -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, accepts@~1.3.8: 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== @@ -1850,11 +3094,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" @@ -1962,6 +3201,15 @@ available-typed-arrays@^1.0.6, available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +axios@^1.7.9: + version "1.7.9" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" + integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -2032,6 +3280,11 @@ base64-js@^1.3.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== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2082,6 +3335,11 @@ body-parser@1.20.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" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2156,7 +3414,20 @@ 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== @@ -2203,11 +3474,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 +3496,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== @@ -2299,17 +3565,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 +3605,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" @@ -2422,11 +3671,6 @@ 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== - comment-json@4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/comment-json/-/comment-json-4.2.3.tgz#50b487ebbf43abe44431f575ebda07d30d015365" @@ -2458,6 +3702,16 @@ 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" @@ -2503,29 +3757,32 @@ cookie@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.5.0: +cookie@0.5.0, 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.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.0.2.tgz#27360701532116bd3f1f9416929d176afe1e4610" + integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA== + +cookie@~0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + 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, 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 +3818,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,13 +3846,15 @@ 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== - dependencies: - es5-ext "^0.10.50" - type "^1.0.1" +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@2.6.9: version "2.6.9" @@ -2611,6 +3877,13 @@ 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" + dedent@^1.0.0: version "1.5.1" resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.1.tgz#4f3fc94c8b711e9bb2800d185cd6ad20f2a90aff" @@ -2621,7 +3894,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== @@ -2670,6 +3943,11 @@ delegates@^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" @@ -2685,6 +3963,11 @@ detect-libc@^2.0.0: 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" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -2708,13 +3991,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 +4022,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.40.0: + version "0.40.0" + resolved "https://registry.yarnpkg.com/drizzle-orm/-/drizzle-orm-0.40.0.tgz#bdcc58d3340ef14e592c6b6c65db89448b08def9" + integrity sha512-7ptk/HQiMSrEZHnAsSlBESXWj52VwgMmyTEfoNmpNN2ZXpcz13LwHfXTIghsAEud7Z5UJhDOp8U07ujcqme7wg== -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" @@ -2825,6 +4089,37 @@ encodeurl@~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" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz#1af946c7d93603eb88e9896cee4904dc012e9c35" @@ -2833,11 +4128,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 +4229,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" @@ -3234,14 +4488,6 @@ etag@~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" @@ -3315,13 +4561,6 @@ express@4.18.2: utils-merge "1.0.1" vary "~1.1.2" -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== - dependencies: - type "^2.7.2" - external-editor@^3.0.3, external-editor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" @@ -3367,6 +4606,13 @@ fast-safe-stringify@2.1.1, fast-safe-stringify@^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-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" + fastq@^1.6.0: version "1.16.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320" @@ -3403,6 +4649,15 @@ 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" + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" @@ -3453,6 +4708,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 +4720,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" @@ -3697,17 +4940,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" @@ -3766,14 +4998,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" @@ -3849,11 +5073,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" @@ -3895,7 +5114,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: 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== @@ -3995,6 +5214,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 +5269,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 +5293,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,18 +5342,6 @@ 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-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" @@ -4183,11 +5400,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 +5415,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 +5468,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== @@ -4672,15 +5879,6 @@ 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" @@ -4772,35 +5970,11 @@ 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" - 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" @@ -4843,11 +6017,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 +6072,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" @@ -4925,12 +6104,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" @@ -4986,34 +6163,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" @@ -5083,20 +6237,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" @@ -5139,14 +6279,6 @@ minizlib@^2.1.1: minipass "^3.0.0" yallist "^4.0.0" -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" - mkdirp@^0.5.4: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" @@ -5154,7 +6286,7 @@ mkdirp@^0.5.4: dependencies: minimist "^1.2.6" -mkdirp@^1.0.3: +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== @@ -5211,11 +6343,32 @@ ms@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== +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.4-lts.1: version "1.4.4-lts.1" resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4-lts.1.tgz#24100f701a4611211cfae94ae16ea39bb314e04d" @@ -5229,6 +6382,19 @@ multer@1.4.4-lts.1: type-is "^1.6.4" xtend "^4.0.0" +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" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + mute-stream@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" @@ -5254,19 +6420,39 @@ 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== +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: - merge-deep "^3.0.3" + 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" -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-i18n@^10.5.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/nestjs-i18n/-/nestjs-i18n-10.5.0.tgz#e2975e2600045e31c92c5b0cfa8a2167dc554c6a" + integrity sha512-Pf95sOk9NiNdEakEEuoxzM8W5Nnt/jj1TavbHeId05aIdTnR6slrvGD4mEur+1fudplrTRa7g/XA0gh5GZ30aw== + dependencies: + accept-language-parser "^1.5.0" + chokidar "^3.5.3" + cookie "^0.5.0" + iterare "^1.2.1" + js-yaml "^4.1.0" + string-format "^2.0.0" + +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== @@ -5290,6 +6476,13 @@ node-fetch@^2.6.1, node-fetch@^2.6.7: 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-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5334,6 +6527,11 @@ object-assign@^4, object-assign@^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,6 +6581,11 @@ object.values@^1.1.7: define-properties "^1.2.0" es-abstract "^1.22.1" +obuf@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + on-finished@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -5552,6 +6755,11 @@ path-type@^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== + pg-cloudflare@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" @@ -5567,11 +6775,21 @@ pg-int8@1.0.1: resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== +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.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-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.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.0.tgz#4c91613c0315349363af2084608db843502f8833" @@ -5588,6 +6806,19 @@ pg-types@^2.1.0: postgres-date "~1.0.4" postgres-interval "^1.1.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.11.3: version "8.11.3" resolved "https://registry.yarnpkg.com/pg/-/pg-8.11.3.tgz#d7db6e3fe268fcedd65b8e4599cda0b8b4bf76cb" @@ -5652,16 +6883,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 +6917,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" @@ -5716,6 +6974,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" @@ -5795,7 +7058,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 +7067,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" @@ -5818,6 +7088,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" @@ -6069,16 +7351,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" @@ -6139,6 +7411,45 @@ 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" + 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" @@ -6189,6 +7500,11 @@ 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" @@ -6199,6 +7515,11 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +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 +7528,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@^1.0.2 || 2 || 3 || 4", 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 +7596,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== @@ -6300,6 +7637,19 @@ 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" + superagent@^8.1.2: version "8.1.2" resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b" @@ -6316,13 +7666,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" @@ -6432,14 +7775,6 @@ through@^2.3.6: 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" @@ -6469,6 +7804,14 @@ toidentifier@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" + tr46@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" @@ -6516,7 +7859,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 +7911,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 +7943,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, type-is@~1.6.18: 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 +7951,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,7 +8005,7 @@ 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== @@ -6684,6 +8027,11 @@ undici-types@~5.26.4: 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" @@ -6724,7 +8072,7 @@ uuid@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== @@ -6877,13 +8225,7 @@ wide-align@^1.1.2: 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 +8243,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 +8274,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" @@ -6971,7 +8332,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: +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==