From 1b02c69ba708b221685a8f3737a0908bf6a847c4 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 2 Jan 2025 14:23:22 +0200 Subject: [PATCH 001/180] feat: changing of port by env --- .env.test | 3 ++- .example.env | 3 ++- src/main.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.env.test b/.env.test index 8684e3e..26c80fc 100644 --- a/.env.test +++ b/.env.test @@ -2,4 +2,5 @@ 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 \ No newline at end of file diff --git a/.example.env b/.example.env index 1b4a4be..5793048 100644 --- a/.example.env +++ b/.example.env @@ -1,4 +1,5 @@ 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 \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 1277f86..acd182c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -53,7 +53,7 @@ async function bootstrap() { swaggerOptions: {}, }); - await app.listen(3000); + await app.listen(process.env?.PORT ?? 6701); } createUserIfDbEmpty(); From 539e2117615a2febf2f27541017f9282f3bd5668 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 2 Jan 2025 16:51:37 +0200 Subject: [PATCH 002/180] feat: filters for workers --- src/@core/decorators/filter.decorator.ts | 87 ++++++++++++++++++++++++ src/drizzle/drizzle-utils.ts | 35 ++++++++++ src/workers/workers.controller.ts | 5 +- src/workers/workers.service.ts | 57 ++++++++-------- 4 files changed, 156 insertions(+), 28 deletions(-) create mode 100644 src/@core/decorators/filter.decorator.ts create mode 100644 src/drizzle/drizzle-utils.ts diff --git a/src/@core/decorators/filter.decorator.ts b/src/@core/decorators/filter.decorator.ts new file mode 100644 index 0000000..b523aff --- /dev/null +++ b/src/@core/decorators/filter.decorator.ts @@ -0,0 +1,87 @@ +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({ + 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({ + title: "Invalid filter format", + description: "Each filter must have field, value and condition", + }); + } + + if (!Object.values(FilterCondition).includes(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({ + title: "Invalid filters format", + description: "Could not parse filters JSON", + }); + } + }, + [ + 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/drizzle/drizzle-utils.ts b/src/drizzle/drizzle-utils.ts new file mode 100644 index 0000000..b217ce2 --- /dev/null +++ b/src/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/workers/workers.controller.ts b/src/workers/workers.controller.ts index 52830b3..ce6dc8b 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -1,4 +1,5 @@ import { Controller } from "@core/decorators/controller.decorator"; +import { FilterParams, IFilters } from "@core/decorators/filter.decorator"; import { IPagination, PaginationParams, @@ -58,11 +59,13 @@ export class WorkersController { }) sorting: ISorting, @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, ): Promise { - const total = await this.workersService.getTotalCount(); + const total = await this.workersService.getTotalCount(filters); const data = await this.workersService.findMany({ pagination, sorting, + filters, }); return { diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index ddef141..a3269ad 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -1,3 +1,4 @@ +import { IFilters } from "@core/decorators/filter.decorator"; import { IPagination } from "@core/decorators/pagination.decorator"; import { ISorting } from "@core/decorators/sorting.decorator"; import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; @@ -8,6 +9,7 @@ import * as argon2 from "argon2"; import { asc, count, desc, eq, sql } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; +import { DrizzleUtils } from "src/drizzle/drizzle-utils"; import { CreateWorkerDto, UpdateWorkerDto } from "./dto/req/put-worker.dto"; import { WorkerEntity } from "./entities/worker.entity"; @@ -26,37 +28,38 @@ export class WorkersService { 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): Promise { + const query = this.pg.select({ value: count() }).from(schema.workers); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(schema.workers, filters)); + } + + return await query.then((res) => res[0].value); } public async findMany(options: { - pagination?: IPagination; - sorting?: ISorting; + pagination: IPagination; + sorting: ISorting; + filters?: IFilters; }): Promise { - const { pagination, sorting } = options; - - return await this.pg.query.workers.findMany({ - // Sorting - ...(sorting - ? { - orderBy: - sorting.sortOrder === "asc" - ? asc(sql.identifier(sorting.sortBy)) - : desc(sql.identifier(sorting.sortBy)), - } - : {}), - // Pagination - ...(pagination - ? { - limit: pagination.size, - offset: pagination.offset, - } - : {}), - }); + const { pagination, sorting, filters } = options; + + const query = this.pg.select().from(schema.workers); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(schema.workers, filters)); + } + + if (sorting) { + query.orderBy( + sorting.sortOrder === "asc" + ? asc(sql.identifier(sorting.sortBy)) + : desc(sql.identifier(sorting.sortBy)), + ); + } + + return await query.limit(pagination.size).offset(pagination.offset); } /** From 14c31039aa56f55ee0db1c9fd0fd72f693e2bba9 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 3 Jan 2025 12:03:48 +0200 Subject: [PATCH 003/180] feat: upgrade drizzle version --- drizzle.config.ts | 7 +- package.json | 18 +- src/@core/decorators/pagination.decorator.ts | 8 +- src/@core/decorators/roles.decorator.ts | 2 +- src/@core/errors/filter.ts | 3 +- src/@core/guards/roles.guard.ts | 2 +- src/@core/interfaces/request.ts | 3 +- src/@core/pipes/validation.pipe.ts | 4 +- src/auth/controllers/auth.controller.ts | 4 +- src/auth/guards/session-auth.guard.ts | 18 +- src/auth/services/auth.service.ts | 4 + src/drizzle/clear.ts | 2 +- src/drizzle/drizzle.module.ts | 16 +- ...tmare.sql => 0000_amazing_enchantress.sql} | 15 +- .../migrations/meta/0000_snapshot.json | 62 ++- src/drizzle/migrations/meta/_journal.json | 10 +- src/drizzle/schema/index.ts | 3 - src/drizzle/schema/restaurants.ts | 21 +- src/drizzle/schema/sessions.ts | 4 +- src/drizzle/schema/workers.ts | 6 +- src/drizzle/seed.ts | 3 +- src/main.ts | 6 +- src/restaurants/dto/restaurant-hours.dto.ts | 2 +- src/restaurants/dto/restaurant.dto.ts | 2 +- .../services/restaurant-hours.service.ts | 24 +- .../services/restaurants.service.ts | 2 +- src/sessions/dto/session-payload.ts | 2 +- src/sessions/sessions.service.ts | 17 +- src/workers/dto/req/put-worker.dto.ts | 10 +- src/workers/entities/worker.entity.ts | 13 +- src/workers/workers.controller.ts | 16 +- src/workers/workers.service.ts | 53 +- test/helpers/db.ts | 2 +- tsconfig.json | 9 +- yarn.lock | 496 +++++------------- 35 files changed, 349 insertions(+), 520 deletions(-) rename src/drizzle/migrations/{0000_fantastic_nightmare.sql => 0000_amazing_enchantress.sql} (77%) delete mode 100644 src/drizzle/schema/index.ts diff --git a/drizzle.config.ts b/drizzle.config.ts index 781d396..3f6c7bf 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,10 +6,11 @@ dotenv.config({ }); export default { - schema: "./src/drizzle/schema/index.ts", + schema: "./src/drizzle/schema", out: "./src/drizzle/migrations", - driver: "pg", + // driver: "pg", + dialect: "postgresql", dbCredentials: { - connectionString: process.env.POSTGRESQL_URL, + url: process.env.POSTGRESQL_URL, }, } satisfies Config; diff --git a/package.json b/package.json index 992458a..29115f8 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,13 @@ "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:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push ", + "db:push:test": "cross-env NODE_ENV=test drizzle-kit push", "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:migrate:drop": "drizzle-kit drop", "db:seed": "node -r esbuild-register src/drizzle/seed.ts" }, "dependencies": { @@ -42,12 +42,12 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", - "drizzle-orm": "^0.29.3", - "drizzle-zod": "^0.5.1", + "drizzle-orm": "0.38.3", + "drizzle-zod": "0.6.1", "eslint-plugin-import": "^2.29.1", "jsonwebtoken": "^9.0.2", "mongoose": "^8.2.0", - "nestjs-zod": "^3.0.0", + "nestjs-zod": "^4.2.0", "passport-jwt": "^4.0.1", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", @@ -66,11 +66,13 @@ "@types/jest": "^29.5.2", "@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", "@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", diff --git a/src/@core/decorators/pagination.decorator.ts b/src/@core/decorators/pagination.decorator.ts index 57fd09a..0ded2db 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; 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/errors/filter.ts b/src/@core/errors/filter.ts index 3642c61..5c5ffda 100644 --- a/src/@core/errors/filter.ts +++ b/src/@core/errors/filter.ts @@ -29,7 +29,8 @@ export class AllExceptionsFilter implements ExceptionFilter { ctx.getResponse(), { statusCode, - errorCode: response.errorCode, + // @ts-expect-error response is not defined + errorCode: response?.errorCode, errorCategory, errorSubCode: null, ...(typeof response?.message === "object" && diff --git a/src/@core/guards/roles.guard.ts b/src/@core/guards/roles.guard.ts index 0b77563..b0e2e0c 100644 --- a/src/@core/guards/roles.guard.ts +++ b/src/@core/guards/roles.guard.ts @@ -3,7 +3,7 @@ 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"; +import { WorkerRole } from "@postgress-db/schema/workers"; @Injectable() export class RolesGuard implements CanActivate { diff --git a/src/@core/interfaces/request.ts b/src/@core/interfaces/request.ts index 847f846..4998ec3 100644 --- a/src/@core/interfaces/request.ts +++ b/src/@core/interfaces/request.ts @@ -1,4 +1,5 @@ -import { ISession, IWorker } from "@postgress-db/schema"; +import { ISession } from "@postgress-db/schema/sessions"; +import { IWorker } from "@postgress-db/schema/workers"; import { Request as Req } from "express"; export interface Request extends Req { diff --git a/src/@core/pipes/validation.pipe.ts b/src/@core/pipes/validation.pipe.ts index 13dd967..57b0cc5 100644 --- a/src/@core/pipes/validation.pipe.ts +++ b/src/@core/pipes/validation.pipe.ts @@ -59,8 +59,8 @@ export class ValidationPipe implements PipeTransform { if (errors.length > 0) { const messages = errors.map(({ constraints }) => { - const [key] = Object.keys(constraints); - return `${constraints[key]}`; + const [key] = Object.keys(constraints ?? {}); + return `${constraints?.[key]}`; }); throw new FormException({ diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 891b11c..ac20ccc 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -19,7 +19,7 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { IWorker } from "@postgress-db/schema"; +import { IWorker } from "@postgress-db/schema/workers"; import { Serializable } from "src/@core/decorators/serializable.decorator"; import { WorkerEntity } from "src/workers/entities/worker.entity"; @@ -81,7 +81,7 @@ export class AuthController { return { ...worker, - setSessionToken: session.token, + setSessionToken: session?.token, }; } diff --git a/src/auth/guards/session-auth.guard.ts b/src/auth/guards/session-auth.guard.ts index 780bed1..179e9bb 100644 --- a/src/auth/guards/session-auth.guard.ts +++ b/src/auth/guards/session-auth.guard.ts @@ -4,6 +4,7 @@ import { Response } from "@core/interfaces/response"; import * as ms from "@lukeed/ms"; import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; +import { IWorker } from "@postgress-db/schema/workers"; import { SessionsService } from "src/sessions/sessions.service"; import { WorkersService } from "src/workers/workers.service"; @@ -47,6 +48,10 @@ export class SessionAuthGuard implements CanActivate { const session = await this.sessionsService.findByToken(token); + if (!session) { + throw new UnauthorizedException(); + } + const isCompromated = session.ipAddress !== ip || session.httpAgent !== httpAgent; @@ -57,7 +62,7 @@ export class SessionAuthGuard implements CanActivate { const isTimeToRefresh = new Date(session.refreshedAt).getTime() + - ms.parse(process.env?.SESSION_EXPIRES_IN || "30m") < + (ms.parse(process.env?.SESSION_EXPIRES_IN ?? "30m") ?? 0) < new Date().getTime(); if (isTimeToRefresh) { @@ -74,18 +79,21 @@ export class SessionAuthGuard implements CanActivate { const worker = await this.workersService.findById(session.workerId); const isTimeToUpdateOnline = - !worker.onlineAt || - new Date(worker.onlineAt).getTime() + ms.parse("5m") < + !worker?.onlineAt || + new Date(worker.onlineAt).getTime() + (ms.parse("5m") ?? 0) < new Date().getTime(); - if (isTimeToUpdateOnline) { + if (isTimeToUpdateOnline && worker) { await this.workersService.update(worker.id, { onlineAt: new Date(), }); } req.session = session; - req.worker = { ...worker, passwordHash: undefined }; + req.worker = { ...worker, passwordHash: undefined } as Omit< + IWorker, + "passwordHash" + > & { passwordHash: undefined }; return true; } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 82034d0..8a98003 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -50,6 +50,10 @@ export class AuthService { ipAddress, }); + if (!created) { + throw new UnauthorizedException("Failed to create session"); + } + return await this.sessionsService.findByToken(created.token); } diff --git a/src/drizzle/clear.ts b/src/drizzle/clear.ts index 1f04caf..bbb8da4 100644 --- a/src/drizzle/clear.ts +++ b/src/drizzle/clear.ts @@ -30,7 +30,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/drizzle/drizzle.module.ts b/src/drizzle/drizzle.module.ts index 7ed41c1..63bd223 100644 --- a/src/drizzle/drizzle.module.ts +++ b/src/drizzle/drizzle.module.ts @@ -1,11 +1,21 @@ import { Module } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { drizzle } from "drizzle-orm/node-postgres"; +import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { PG_CONNECTION } from "../constants"; -import * as schema from "./schema"; +import * as restaurants from "./schema/restaurants"; +import * as sessions from "./schema/sessions"; +import * as workers from "./schema/workers"; + +export const schema = { + ...restaurants, + ...sessions, + ...workers, +}; + +export type Schema = typeof schema; @Module({ providers: [ @@ -19,7 +29,7 @@ import * as schema from "./schema"; ssl: process.env.NODE_ENV === "production" ? true : false, }); - return drizzle(pool, { schema }); + return drizzle(pool, { schema }) as NodePgDatabase; }, }, ], diff --git a/src/drizzle/migrations/0000_fantastic_nightmare.sql b/src/drizzle/migrations/0000_amazing_enchantress.sql similarity index 77% rename from src/drizzle/migrations/0000_fantastic_nightmare.sql rename to src/drizzle/migrations/0000_amazing_enchantress.sql index e3a74b0..56ca542 100644 --- a/src/drizzle/migrations/0000_fantastic_nightmare.sql +++ b/src/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/drizzle/migrations/meta/0000_snapshot.json index 60ca768..aeeb5c6 100644 --- a/src/drizzle/migrations/meta/0000_snapshot.json +++ b/src/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/drizzle/migrations/meta/_journal.json b/src/drizzle/migrations/meta/_journal.json index b9863cb..e3fbd4c 100644 --- a/src/drizzle/migrations/meta/_journal.json +++ b/src/drizzle/migrations/meta/_journal.json @@ -1,12 +1,12 @@ { - "version": "5", - "dialect": "pg", + "version": "7", + "dialect": "postgresql", "entries": [ { "idx": 0, - "version": "5", - "when": 1707137951517, - "tag": "0000_fantastic_nightmare", + "version": "7", + "when": 1735886695760, + "tag": "0000_amazing_enchantress", "breakpoints": true } ] 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 index 2c74073..8aa4b7c 100644 --- a/src/drizzle/schema/restaurants.ts +++ b/src/drizzle/schema/restaurants.ts @@ -13,33 +13,30 @@ import { workers } from "./workers"; export const restaurants = pgTable("restaurants", { // Primary key - id: uuid("id").defaultRandom(), + 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("legalEntity"), + legalEntity: text("legalEntity").notNull(), // Address of the restaurant // - address: text("address"), - latitude: numeric("latitude"), - longitude: numeric("longitude"), + address: text("address").notNull(), + latitude: numeric("latitude").notNull(), + longitude: numeric("longitude").notNull(), // Is the restaurant enabled? // - isEnabled: boolean("isEnabled").default(false), + isEnabled: boolean("isEnabled").notNull().default(false), // Timestamps // - createdAt: timestamp("createdAt") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - + createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); export const restaurantHours = pgTable("restaurantHours", { // Primary key // - id: uuid("id").defaultRandom(), + id: uuid("id").defaultRandom().primaryKey(), // Restaurant // restaurantId: uuid("restaurantId").notNull(), @@ -51,7 +48,7 @@ export const restaurantHours = pgTable("restaurantHours", { openingTime: time("openingTime").notNull(), closingTime: time("closingTime").notNull(), - isEnabled: boolean("isEnabled").default(true), + isEnabled: boolean("isEnabled").notNull().default(true), // Timestamps // createdAt: timestamp("createdAt").notNull().defaultNow(), diff --git a/src/drizzle/schema/sessions.ts b/src/drizzle/schema/sessions.ts index 96acd03..7bf4bb1 100644 --- a/src/drizzle/schema/sessions.ts +++ b/src/drizzle/schema/sessions.ts @@ -4,12 +4,12 @@ import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { workers } from "./workers"; export const sessions = pgTable("sessions", { - id: uuid("id").defaultRandom(), + id: uuid("id").primaryKey().defaultRandom(), workerId: uuid("workerId").notNull(), httpAgent: text("httpAgent"), ipAddress: text("ipAddress"), token: text("token").notNull().unique(), - refreshedAt: timestamp("refreshedAt"), + refreshedAt: timestamp("refreshedAt").notNull().defaultNow(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); diff --git a/src/drizzle/schema/workers.ts b/src/drizzle/schema/workers.ts index 6d3b656..639576b 100644 --- a/src/drizzle/schema/workers.ts +++ b/src/drizzle/schema/workers.ts @@ -26,13 +26,13 @@ export const workerRoleEnum = pgEnum("workerRoleEnum", [ export const ZodWorkerRole = z.enum(workerRoleEnum.enumValues); export const workers = pgTable("workers", { - id: uuid("id").defaultRandom(), - name: text("name"), + id: uuid("id").defaultRandom().primaryKey(), + name: text("name").notNull().default("N/A"), restaurantId: uuid("restaurantId"), login: text("login").unique().notNull(), role: workerRoleEnum("role").notNull(), passwordHash: text("passwordHash").notNull(), - isBlocked: boolean("isBlocked").default(false), + isBlocked: boolean("isBlocked").notNull().default(false), hiredAt: timestamp("hiredAt"), firedAt: timestamp("firedAt"), onlineAt: timestamp("onlineAt"), diff --git a/src/drizzle/seed.ts b/src/drizzle/seed.ts index eaf6d5e..62ec8bd 100644 --- a/src/drizzle/seed.ts +++ b/src/drizzle/seed.ts @@ -1,10 +1,9 @@ +import { schema } from "@postgress-db/drizzle.module"; 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); diff --git a/src/main.ts b/src/main.ts index acd182c..8d0aa33 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,12 @@ import { configApp } from "@core/config/app"; import { NestFactory } from "@nestjs/core"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; -import { workers } from "@postgress-db/schema"; +import { schema } from "@postgress-db/drizzle.module"; +import { workers } from "@postgress-db/schema/workers"; 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"; @@ -20,7 +20,7 @@ export const createUserIfDbEmpty = async () => { if ((await db.query.workers.findMany()).length === 0) { await db.insert(workers).values({ login: "admin", - passwordHash: await hash(process.env.INITIAL_ADMIN_PASSWORD), + passwordHash: await hash(process.env.INITIAL_ADMIN_PASSWORD ?? "123456"), role: schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, }); } diff --git a/src/restaurants/dto/restaurant-hours.dto.ts b/src/restaurants/dto/restaurant-hours.dto.ts index b4dd160..ec14dcd 100644 --- a/src/restaurants/dto/restaurant-hours.dto.ts +++ b/src/restaurants/dto/restaurant-hours.dto.ts @@ -1,5 +1,5 @@ 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"; diff --git a/src/restaurants/dto/restaurant.dto.ts b/src/restaurants/dto/restaurant.dto.ts index b6a94db..d078d6e 100644 --- a/src/restaurants/dto/restaurant.dto.ts +++ b/src/restaurants/dto/restaurant.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IRestaurant } from "@postgress-db/schema"; +import { IRestaurant } from "@postgress-db/schema/restaurants"; import { Expose } from "class-transformer"; import { IsBoolean, diff --git a/src/restaurants/services/restaurant-hours.service.ts b/src/restaurants/services/restaurant-hours.service.ts index 2ef4a82..a01e69b 100644 --- a/src/restaurants/services/restaurant-hours.service.ts +++ b/src/restaurants/services/restaurant-hours.service.ts @@ -1,6 +1,6 @@ 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"; @@ -53,7 +53,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), }); @@ -76,11 +76,9 @@ export class RestaurantHoursService { 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]; } /** @@ -97,12 +95,13 @@ export class RestaurantHoursService { throw new BadRequestException(`Restaurant 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]; } /** @@ -118,8 +117,11 @@ export class RestaurantHoursService { throw new BadRequestException(`Restaurant hours with id ${id} not found`); } - 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/services/restaurants.service.ts b/src/restaurants/services/restaurants.service.ts index 3284445..af6bb42 100644 --- a/src/restaurants/services/restaurants.service.ts +++ b/src/restaurants/services/restaurants.service.ts @@ -1,7 +1,7 @@ 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 { schema } from "@postgress-db/drizzle.module"; import { count, eq } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; diff --git a/src/sessions/dto/session-payload.ts b/src/sessions/dto/session-payload.ts index f70b21c..7eac1f9 100644 --- a/src/sessions/dto/session-payload.ts +++ b/src/sessions/dto/session-payload.ts @@ -1,4 +1,4 @@ -import { sessions } from "@postgress-db/schema"; +import { sessions } from "@postgress-db/schema/sessions"; import { Expose } from "class-transformer"; import { IsUUID } from "class-validator"; import { createInsertSchema } from "drizzle-zod"; diff --git a/src/sessions/sessions.service.ts b/src/sessions/sessions.service.ts index 47a952c..b17093c 100644 --- a/src/sessions/sessions.service.ts +++ b/src/sessions/sessions.service.ts @@ -1,7 +1,8 @@ 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 { schema } from "@postgress-db/drizzle.module"; +import { ISession, sessions } from "@postgress-db/schema/sessions"; import { eq } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; @@ -19,7 +20,7 @@ export class SessionsService { return `v1-${uuidv4()}-${uuidv4()}-${uuidv4()}-${uuidv4()}`; } - public async findByToken(token: string): Promise { + public async findByToken(token: string): Promise { try { const result = await this.pg.query.sessions.findFirst({ where: eq(schema.sessions.token, token), @@ -33,13 +34,13 @@ export class SessionsService { public async create( dto: SessionPayloadDto, - ): Promise> { + ): Promise | undefined> { try { const { workerId, httpAgent, ipAddress } = dto; const token = this.generateToken(); return await this.pg - .insert(schema.sessions) + .insert(sessions) .values({ workerId, httpAgent, @@ -59,7 +60,7 @@ export class SessionsService { } } - public async refresh(token: string): Promise { + public async refresh(token: string): Promise { try { if (!(await this.isSessionValid(token))) { throw new UnauthorizedException("Session is expired"); @@ -71,7 +72,7 @@ export class SessionsService { .update(schema.sessions) .set({ token: newToken, - refreshedAt: new Date(), + // refreshedAt: new Date(), updatedAt: new Date(), }) .where(eq(schema.sessions.token, token)); @@ -86,7 +87,7 @@ export class SessionsService { try { const session = await this.findByToken(token); - if (!session) return false; + if (!session || !session?.refreshedAt) return false; // If session is older than 7 days, it's expired const isExpired = @@ -95,6 +96,7 @@ export class SessionsService { return !isExpired; } catch (err) { handleError(err); + return false; } } @@ -107,6 +109,7 @@ export class SessionsService { return true; } catch (err) { handleError(err); + return false; } } } diff --git a/src/workers/dto/req/put-worker.dto.ts b/src/workers/dto/req/put-worker.dto.ts index 2ef8b76..10f8069 100644 --- a/src/workers/dto/req/put-worker.dto.ts +++ b/src/workers/dto/req/put-worker.dto.ts @@ -5,7 +5,7 @@ import { PickType, } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsStrongPassword } from "class-validator"; +import { IsDate, IsOptional, IsStrongPassword } from "class-validator"; import { WorkerEntity } from "src/workers/entities/worker.entity"; export class CreateWorkerDto extends IntersectionType( @@ -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..a112499 100644 --- a/src/workers/entities/worker.entity.ts +++ b/src/workers/entities/worker.entity.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IWorker, ZodWorkerRole } from "@postgress-db/schema"; +import { IWorker, ZodWorkerRole } from "@postgress-db/schema/workers"; import { Exclude, Expose } from "class-transformer"; import { IsBoolean, @@ -20,12 +20,11 @@ export class WorkerEntity implements IWorker { }) id: string; - @IsOptional() @IsString() @Expose() @ApiProperty({ description: "Name of the worker", - example: "Dana Keller", + example: "V Keller", }) name: string; @@ -43,7 +42,7 @@ export class WorkerEntity implements IWorker { @Expose() @ApiProperty({ description: "Login of the worker", - example: "dana.keller", + example: "v.keller", }) login: string; @@ -76,7 +75,7 @@ export class WorkerEntity implements IWorker { example: new Date("2021-08-01T00:00:00.000Z"), type: Date, }) - hiredAt: Date; + hiredAt: Date | null; @IsOptional() @IsISO8601() @@ -86,7 +85,7 @@ export class WorkerEntity implements IWorker { example: null, type: Date, }) - firedAt: Date; + firedAt: Date | null; @IsOptional() @IsISO8601() @@ -96,7 +95,7 @@ export class WorkerEntity implements IWorker { example: new Date(), type: Date, }) - onlineAt: Date; + onlineAt: Date | null; @IsISO8601() @Expose() diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index ce6dc8b..75593ac 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -23,7 +23,11 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { IWorker, workerRoleRank } from "@postgress-db/schema"; +import { + IWorker, + WorkerRole, + workerRoleRank, +} from "@postgress-db/schema/workers"; import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { CreateWorkerDto, UpdateWorkerDto } from "./dto/req/put-worker.dto"; @@ -171,7 +175,7 @@ export class WorkersController { const { role } = data; - const roleRank = workerRoleRank?.[role]; + const roleRank = workerRoleRank?.[role as WorkerRole]; const requesterRoleRank = workerRoleRank[worker.role]; if (role) { @@ -190,9 +194,15 @@ export class WorkersController { } } - return await this.workersService.update(id, { + const updatedWorker = await this.workersService.update(id, { ...data, updatedAt: new Date(), }); + + if (!updatedWorker) { + throw new NotFoundException("Worker with this id doesn't exist"); + } + + return updatedWorker; } } diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index a3269ad..69d9ca5 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -3,8 +3,10 @@ import { IPagination } from "@core/decorators/pagination.decorator"; import { ISorting } from "@core/decorators/sorting.decorator"; import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; import { ConflictException } from "@core/errors/exceptions/conflict.exception"; +import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; import { Inject, Injectable } from "@nestjs/common"; -import * as schema from "@postgress-db/schema"; +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 { NodePgDatabase } from "drizzle-orm/node-postgres"; @@ -20,7 +22,7 @@ export class WorkersService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} - private checkRestaurantRoleAssignment(role?: schema.IWorker["role"]) { + private checkRestaurantRoleAssignment(role?: IWorker["role"]) { if (role === "SYSTEM_ADMIN" || role === "CHIEF_ADMIN") { throw new BadRequestException("You can't assign restaurant to this role"); } @@ -67,7 +69,7 @@ export class WorkersService { * @param id number id of worker * @returns */ - public async findById(id: string): Promise { + public async findById(id: string): Promise { return await this.pg.query.workers.findFirst({ where: eq(schema.workers.id, id), }); @@ -78,7 +80,7 @@ export class WorkersService { * @param value string login * @returns */ - public async findOneByLogin(value: string): Promise { + public async findOneByLogin(value: string): Promise { return await this.pg.query.workers.findFirst({ where: eq(schema.workers.login, value), }); @@ -89,19 +91,27 @@ export class WorkersService { * @param dto * @returns */ - public async create(dto: CreateWorkerDto): Promise { + public async create(dto: CreateWorkerDto): Promise { const { password, role, restaurantId, ...rest } = dto; if (restaurantId) { 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 + .insert(schema.workers) + .values({ + ...rest, + // restaurantId, + role, + passwordHash: await argon2.hash(password), + }) + .returning(); + + const worker = workers[0]; + if (!worker || !worker.login) { + throw new ServerErrorException("Failed to create worker"); + } return await this.findOneByLogin(worker.login); } @@ -112,17 +122,22 @@ export class WorkersService { * @param dto * @returns */ - public async update(id: string, dto: UpdateWorkerDto): Promise { + public async update( + id: string, + dto: UpdateWorkerDto, + ): Promise { const { password, role, login, restaurantId, ...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("Worker with this login already exists"); + } } if (restaurantId) { diff --git a/test/helpers/db.ts b/test/helpers/db.ts index 53fb205..bd5b49f 100644 --- a/test/helpers/db.ts +++ b/test/helpers/db.ts @@ -1,6 +1,6 @@ +import { schema } from "@postgress-db/drizzle.module"; 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 }), diff --git a/tsconfig.json b/tsconfig.json index a64e353..696f536 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,15 +12,16 @@ "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/*"], }, }, + "include": ["src/**/*"], "exclude": ["node_modules", "dist"], } diff --git a/yarn.lock b/yarn.lock index 10f2931..7e8ef1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -349,12 +349,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" @@ -960,6 +958,11 @@ dependencies: sparse-bitfield "^3.0.3" +"@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/cli@^10.0.0": version "10.3.0" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-10.3.0.tgz#5f9ef49a60baf4b39cb87e4b74240f7c9339e923" @@ -1375,6 +1378,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,6 +1441,11 @@ "@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" @@ -1850,11 +1867,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" @@ -2203,11 +2215,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 +2237,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 +2306,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,17 +2346,6 @@ 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" @@ -2422,11 +2407,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" @@ -2513,13 +2493,6 @@ cookiejar@^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" @@ -2582,14 +2555,6 @@ 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" - debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -2621,7 +2586,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== @@ -2708,13 +2673,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 +2704,25 @@ 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" - -drizzle-kit@^0.20.13: - version "0.20.13" - resolved "https://registry.yarnpkg.com/drizzle-kit/-/drizzle-kit-0.20.13.tgz#6aa45a3b445ecfd05becb7bb679a54a3194f4fe4" - integrity sha512-j9oZSQXNWG+KBJm0Sg3S/zJpncHGKnpqNfFuM4NUxUMGTcihDHhP9SW6Jncqwb5vsP1Xm0a8JLm3PZUIspC/oA== +drizzle-kit@^0.30.1: + version "0.30.1" + resolved "https://registry.yarnpkg.com/drizzle-kit/-/drizzle-kit-0.30.1.tgz#79f000fdd96d837cc63d30b2e4b32d34c2cd4154" + integrity sha512-HmA/NeewvHywhJ2ENXD3KvOuM/+K2dGLJfxVfIHsGwaqKICJnS+Ke2L6UcSrSrtMJLJaT0Im1Qv4TFXfaZShyw== dependencies: - "@drizzle-team/studio" "^0.0.39" + "@drizzle-team/brocli" "^0.10.2" "@esbuild-kit/esm-loader" "^2.5.5" - camelcase "^7.0.1" - chalk "^5.2.0" - commander "^9.4.1" - env-paths "^3.0.0" esbuild "^0.19.7" esbuild-register "^3.5.0" - glob "^8.1.0" - hanji "^0.0.5" - json-diff "0.9.0" - minimatch "^7.4.3" - semver "^7.5.4" - zod "^3.20.2" -drizzle-orm@^0.29.3: - version "0.29.3" - resolved "https://registry.yarnpkg.com/drizzle-orm/-/drizzle-orm-0.29.3.tgz#a9b9beb235bbdf9100e62432390cadbee95345cf" - integrity sha512-uSE027csliGSGYD0pqtM+SAQATMREb3eSM/U8s6r+Y0RFwTKwftnwwSkqx3oS65UBgqDOM0gMTl5UGNpt6lW0A== +drizzle-orm@0.38.3: + version "0.38.3" + resolved "https://registry.yarnpkg.com/drizzle-orm/-/drizzle-orm-0.38.3.tgz#2bdf9a649eda9731cfd3f39b2fdaf6bf844be492" + integrity sha512-w41Y+PquMpSff/QDRGdItG0/aWca+/J3Sda9PPGkTxBtjWQvgU1jxlFBXdjog5tYvTu58uvi3PwR1NuCx0KeZg== -drizzle-zod@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/drizzle-zod/-/drizzle-zod-0.5.1.tgz#4e5efe016dce22ed01063f72f839b07670b2d11e" - integrity sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A== +drizzle-zod@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/drizzle-zod/-/drizzle-zod-0.6.1.tgz#2024a9ece75f27748c7a71ee48a094ee1957fd90" + integrity sha512-huEbUgnsuR8tupnmLiyB2F1I2H9dswI3GfM36IbIqx9i0YUeYjRsDpJVyFVeziUvI1ogT9JHRL2Q03cC4QmvxA== eastasianwidth@^0.2.0: version "0.2.0" @@ -2833,11 +2774,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 +2875,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 +3134,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 +3207,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" @@ -3460,23 +3345,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 +3565,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 +3623,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 +3698,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" @@ -4035,7 +3879,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 +3903,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 +3952,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 +4010,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 +4025,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" @@ -4672,15 +4489,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 +4580,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" @@ -4888,11 +4672,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,13 +4704,6 @@ 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" - magic-string@0.30.5: version "0.30.5" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" @@ -4986,34 +4758,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 +4832,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 +4874,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" @@ -5254,17 +4981,13 @@ 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-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: - merge-deep "^3.0.3" - -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== + "@nest-zod/z" "*" + deepmerge "^4.3.1" node-abort-controller@^3.0.1: version "3.1.1" @@ -5383,6 +5106,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" @@ -5567,11 +5295,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 +5326,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 +5403,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 +5437,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" @@ -6069,16 +5847,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" @@ -6207,7 +5975,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 +6043,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== @@ -6316,13 +6100,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 +6209,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" @@ -6598,16 +6367,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" @@ -6877,13 +6636,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 +6654,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" @@ -6971,7 +6733,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== From c77bdde0b095b08f63e030da4a9a1dcad93a7ea7 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 3 Jan 2025 14:21:53 +0200 Subject: [PATCH 004/180] feat: addresses suggestions --- .env.test | 4 +- .example.env | 4 +- package.json | 4 + src/addresses/addresses.controller.ts | 52 ++++ src/addresses/addresses.module.ts | 15 ++ src/addresses/dto/get-suggestions.dto.ts | 59 +++++ src/addresses/entities/suggestion.entity.ts | 268 ++++++++++++++++++++ src/addresses/services/addresses.service.ts | 30 +++ src/addresses/services/dadata.service.ts | 76 ++++++ src/addresses/services/google.service.ts | 88 +++++++ src/app.module.ts | 2 + yarn.lock | 44 ++++ 12 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 src/addresses/addresses.controller.ts create mode 100644 src/addresses/addresses.module.ts create mode 100644 src/addresses/dto/get-suggestions.dto.ts create mode 100644 src/addresses/entities/suggestion.entity.ts create mode 100644 src/addresses/services/addresses.service.ts create mode 100644 src/addresses/services/dadata.service.ts create mode 100644 src/addresses/services/google.service.ts diff --git a/.env.test b/.env.test index 26c80fc..26fb5fc 100644 --- a/.env.test +++ b/.env.test @@ -3,4 +3,6 @@ MONGO_URL=mongodb://admin:123456@192.168.1.120:27017/db?authSource=admin JWT_SECRET=secret SESSION_EXPIRES_IN=1h INITIAL_ADMIN_PASSWORD=123456 -PORT=6701 \ No newline at end of file +PORT=6701 +DADATA_API_TOKEN= +GOOGLE_MAPS_API_KEY= \ No newline at end of file diff --git a/.example.env b/.example.env index 5793048..63bb897 100644 --- a/.example.env +++ b/.example.env @@ -2,4 +2,6 @@ POSTGRESQL_URL=postgres://root:password@164.92.197.53:8333/testdb JWT_SECRET=secret SESSION_EXPIRES_IN=1s INITIAL_ADMIN_PASSWORD=123456 -PORT=6701 \ No newline at end of file +PORT=6701 +DADATA_API_TOKEN= +GOOGLE_MAPS_API_KEY= \ No newline at end of file diff --git a/package.json b/package.json index 29115f8..fa2ce7b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@lukeed/ms": "^2.0.2", + "@nestjs/axios": "^3.1.3", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", @@ -39,6 +40,7 @@ "@nestjs/swagger": "^7.2.0", "@nestjs/throttler": "^5.1.1", "argon2": "^0.31.2", + "axios": "^1.7.9", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", @@ -47,6 +49,7 @@ "eslint-plugin-import": "^2.29.1", "jsonwebtoken": "^9.0.2", "mongoose": "^8.2.0", + "multer": "^1.4.5-lts.1", "nestjs-zod": "^4.2.0", "passport-jwt": "^4.0.1", "pg": "^8.11.3", @@ -64,6 +67,7 @@ "@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", diff --git a/src/addresses/addresses.controller.ts b/src/addresses/addresses.controller.ts new file mode 100644 index 0000000..fa82048 --- /dev/null +++ b/src/addresses/addresses.controller.ts @@ -0,0 +1,52 @@ +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 { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; + +import { GetSuggestionsDto } from "./dto/get-suggestions.dto"; +import { AddressSuggestion } from "./entities/suggestion.entity"; +import { AddressesService } from "./services/addresses.service"; + +@RequireSessionAuth() +@Controller("addresses", { + tags: ["Addresses"], +}) +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class AddressesController { + constructor(private readonly addressesService: AddressesService) {} + + @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..0663b5e --- /dev/null +++ b/src/addresses/dto/get-suggestions.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose, Transform } from "class-transformer"; +import { + IsBoolean, + IsEnum, + IsOptional, + IsString, + MinLength, +} from "class-validator"; + +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..0f50af2 --- /dev/null +++ b/src/addresses/entities/suggestion.entity.ts @@ -0,0 +1,268 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsOptional } from "class-validator"; + +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..aa0e89c --- /dev/null +++ b/src/addresses/services/dadata.service.ts @@ -0,0 +1,76 @@ +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 = process.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..0260c6f --- /dev/null +++ b/src/addresses/services/google.service.ts @@ -0,0 +1,88 @@ +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 = process.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..ff47199 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { APP_FILTER, APP_GUARD, APP_PIPE } from "@nestjs/core"; import { MongooseModule } from "@nestjs/mongoose"; import { ThrottlerModule } from "@nestjs/throttler"; import { ZodValidationPipe } from "nestjs-zod"; +import { AddressesModule } from "src/addresses/addresses.module"; import { AuthModule } from "./auth/auth.module"; import { SessionAuthGuard } from "./auth/guards/session-auth.guard"; @@ -36,6 +37,7 @@ import { WorkersModule } from "./workers/workers.module"; AuthModule, WorkersModule, RestaurantsModule, + AddressesModule, ], providers: [ { diff --git a/yarn.lock b/yarn.lock index 7e8ef1c..1278cd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -963,6 +963,11 @@ 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/cli@^10.0.0": version "10.3.0" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-10.3.0.tgz#5f9ef49a60baf4b39cb87e4b74240f7c9339e923" @@ -1348,6 +1353,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" @@ -1974,6 +1986,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" @@ -3338,6 +3359,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" @@ -4956,6 +4982,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" @@ -5494,6 +5533,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" From 932d63fc60e5144d8df24da37166836d37025329 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 3 Jan 2025 14:33:28 +0200 Subject: [PATCH 005/180] feat: add redis --- .env.test | 3 +- .example.env | 3 +- package.json | 2 + src/@base/redis/redis.utils.ts | 19 ++++++++++ src/app.module.ts | 6 +++ yarn.lock | 69 ++++++++++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/@base/redis/redis.utils.ts diff --git a/.env.test b/.env.test index 26fb5fc..7f641aa 100644 --- a/.env.test +++ b/.env.test @@ -5,4 +5,5 @@ SESSION_EXPIRES_IN=1h INITIAL_ADMIN_PASSWORD=123456 PORT=6701 DADATA_API_TOKEN= -GOOGLE_MAPS_API_KEY= \ No newline at end of file +GOOGLE_MAPS_API_KEY= +REDIS_URL=redis://localhost:6379 \ No newline at end of file diff --git a/.example.env b/.example.env index 63bb897..26610a7 100644 --- a/.example.env +++ b/.example.env @@ -4,4 +4,5 @@ SESSION_EXPIRES_IN=1s INITIAL_ADMIN_PASSWORD=123456 PORT=6701 DADATA_API_TOKEN= -GOOGLE_MAPS_API_KEY= \ No newline at end of file +GOOGLE_MAPS_API_KEY= +REDIS_URL= \ No newline at end of file diff --git a/package.json b/package.json index fa2ce7b..9d5918a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "db:seed": "node -r esbuild-register src/drizzle/seed.ts" }, "dependencies": { + "@liaoliaots/nestjs-redis": "^10.0.0", "@lukeed/ms": "^2.0.2", "@nestjs/axios": "^3.1.3", "@nestjs/common": "^10.0.0", @@ -47,6 +48,7 @@ "drizzle-orm": "0.38.3", "drizzle-zod": "0.6.1", "eslint-plugin-import": "^2.29.1", + "ioredis": "^5.4.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.2.0", "multer": "^1.4.5-lts.1", diff --git a/src/@base/redis/redis.utils.ts b/src/@base/redis/redis.utils.ts new file mode 100644 index 0000000..c12a37f --- /dev/null +++ b/src/@base/redis/redis.utils.ts @@ -0,0 +1,19 @@ +export class RedisUtils { + public static buildKey(key: string | string[] | Record) { + const appName = "toite-api-instance"; + const version = "1.0.0"; + const env = process.env.NODE_ENV; + + const keyParts = [appName, version, 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/app.module.ts b/src/app.module.ts index ff47199..35d1aef 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,6 @@ import { AllExceptionsFilter } from "@core/errors/filter"; import { RolesGuard } from "@core/guards/roles.guard"; +import { RedisModule } from "@liaoliaots/nestjs-redis"; import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { APP_FILTER, APP_GUARD, APP_PIPE } from "@nestjs/core"; @@ -34,6 +35,11 @@ import { WorkersModule } from "./workers/workers.module"; uri: configService.get("MONGO_URL"), }), }), + RedisModule.forRoot({ + config: { + url: process.env.REDIS_URL, + }, + }), AuthModule, WorkersModule, RestaurantsModule, diff --git a/yarn.lock b/yarn.lock index 1278cd0..36372af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -651,6 +651,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" @@ -919,6 +924,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" @@ -2372,6 +2384,11 @@ clone@^1.0.2: 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" @@ -2656,6 +2673,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" @@ -3865,6 +3887,21 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== +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" @@ -4653,11 +4690,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" @@ -5640,6 +5687,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" @@ -6001,6 +6060,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" @@ -6381,6 +6445,11 @@ 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== + 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" From 1dec2a62b04c51d0e3c9fba2d8a0ed4a9693b624 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 6 Jan 2025 14:30:50 +0200 Subject: [PATCH 006/180] feat: closed forever for restaurants --- src/drizzle/schema/restaurants.ts | 5 ++++- src/restaurants/controllers/restaurants.controller.ts | 2 +- src/restaurants/dto/create-restaurant.dto.ts | 3 ++- src/restaurants/dto/restaurant-with-hours.dto.ts | 3 ++- src/restaurants/dto/views/get-restaurants.view.ts | 2 +- .../restaurant.dto.ts => entities/restaurant.entity.ts} | 8 ++++++++ src/restaurants/services/restaurants.service.ts | 7 ++++++- 7 files changed, 24 insertions(+), 6 deletions(-) rename src/restaurants/{dto/restaurant.dto.ts => entities/restaurant.entity.ts} (92%) diff --git a/src/drizzle/schema/restaurants.ts b/src/drizzle/schema/restaurants.ts index 8aa4b7c..d20c827 100644 --- a/src/drizzle/schema/restaurants.ts +++ b/src/drizzle/schema/restaurants.ts @@ -1,4 +1,4 @@ -import { relations, sql } from "drizzle-orm"; +import { relations } from "drizzle-orm"; import { boolean, numeric, @@ -29,6 +29,9 @@ export const restaurants = pgTable("restaurants", { // Is the restaurant enabled? // isEnabled: boolean("isEnabled").notNull().default(false), + // Is closed forever? // + isClosedForever: boolean("isClosedForever").notNull().default(false), + // Timestamps // createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), diff --git a/src/restaurants/controllers/restaurants.controller.ts b/src/restaurants/controllers/restaurants.controller.ts index 66fc18a..55546a4 100644 --- a/src/restaurants/controllers/restaurants.controller.ts +++ b/src/restaurants/controllers/restaurants.controller.ts @@ -18,9 +18,9 @@ import { import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; -import { RestaurantDto } from "../dto/restaurant.dto"; import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; import { RestaurantsPaginatedDto } from "../dto/views/get-restaurants.view"; +import { RestaurantDto } from "../entities/restaurant.entity"; import { RestaurantsService } from "../services/restaurants.service"; @RequireSessionAuth() diff --git a/src/restaurants/dto/create-restaurant.dto.ts b/src/restaurants/dto/create-restaurant.dto.ts index df6eb71..b6adc0d 100644 --- a/src/restaurants/dto/create-restaurant.dto.ts +++ b/src/restaurants/dto/create-restaurant.dto.ts @@ -1,6 +1,6 @@ import { PickType } from "@nestjs/swagger"; -import { RestaurantDto } from "./restaurant.dto"; +import { RestaurantDto } from "../entities/restaurant.entity"; export class CreateRestaurantDto extends PickType(RestaurantDto, [ "name", @@ -9,4 +9,5 @@ export class CreateRestaurantDto extends PickType(RestaurantDto, [ "latitude", "longitude", "isEnabled", + "isClosedForever", ]) {} diff --git a/src/restaurants/dto/restaurant-with-hours.dto.ts b/src/restaurants/dto/restaurant-with-hours.dto.ts index 76840f3..05a8f15 100644 --- a/src/restaurants/dto/restaurant-with-hours.dto.ts +++ b/src/restaurants/dto/restaurant-with-hours.dto.ts @@ -1,8 +1,9 @@ import { ApiProperty } from "@nestjs/swagger"; import { Expose, Type } from "class-transformer"; +import { RestaurantDto } from "../entities/restaurant.entity"; + import { RestaurantHoursDto } from "./restaurant-hours.dto"; -import { RestaurantDto } from "./restaurant.dto"; export class RestaurantWithHoursDto extends RestaurantDto { @Expose() diff --git a/src/restaurants/dto/views/get-restaurants.view.ts b/src/restaurants/dto/views/get-restaurants.view.ts index c452262..33f5301 100644 --- a/src/restaurants/dto/views/get-restaurants.view.ts +++ b/src/restaurants/dto/views/get-restaurants.view.ts @@ -2,7 +2,7 @@ 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 { RestaurantDto } from "../../entities/restaurant.entity"; export class RestaurantsPaginatedDto extends PaginationResponseDto { @Expose() diff --git a/src/restaurants/dto/restaurant.dto.ts b/src/restaurants/entities/restaurant.entity.ts similarity index 92% rename from src/restaurants/dto/restaurant.dto.ts rename to src/restaurants/entities/restaurant.entity.ts index d078d6e..ddf8980 100644 --- a/src/restaurants/dto/restaurant.dto.ts +++ b/src/restaurants/entities/restaurant.entity.ts @@ -71,6 +71,14 @@ export class RestaurantDto implements IRestaurant { }) isEnabled: boolean; + @IsBoolean() + @Expose() + @ApiProperty({ + description: "Is restaurant closed forever", + example: false, + }) + isClosedForever: boolean; + @IsISO8601() @Expose() @ApiProperty({ diff --git a/src/restaurants/services/restaurants.service.ts b/src/restaurants/services/restaurants.service.ts index af6bb42..2b60959 100644 --- a/src/restaurants/services/restaurants.service.ts +++ b/src/restaurants/services/restaurants.service.ts @@ -7,8 +7,8 @@ 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"; +import { RestaurantDto } from "../entities/restaurant.entity"; @Injectable() export class RestaurantsService { @@ -84,6 +84,11 @@ export class RestaurantsService { id: string, dto: UpdateRestaurantDto, ): Promise { + // Disable restaurant if it is closed forever + if (dto.isClosedForever) { + dto.isEnabled = false; + } + await this.pg .update(schema.restaurants) .set(dto) From 47a43b2ac519721f3590d702d0a35043553c1666 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 6 Jan 2025 15:38:31 +0200 Subject: [PATCH 007/180] feat: drizzle db workshops and restaurants structure refactor --- package.json | 3 +- src/@core/types/general.ts | 11 +++ src/drizzle/schema/general.ts | 11 +++ src/drizzle/schema/restaurant-workshop.ts | 66 +++++++++++++++++ src/drizzle/schema/restaurants.ts | 5 +- src/drizzle/schema/workers.ts | 6 +- .../controllers/restaurants.controller.ts | 20 +++--- .../{ => @}/dto/create-restaurant.dto.ts | 4 +- .../@/dto/restaurant-with-hours.dto.ts | 15 ++++ .../{ => @}/dto/update-restaurant.dto.ts | 0 .../{ => @}/dto/views/get-restaurants.view.ts | 8 +-- .../{ => @}/entities/restaurant.entity.ts | 2 +- .../{ => @}/services/restaurants.service.ts | 10 +-- .../dto/restaurant-with-hours.dto.ts | 16 ----- .../entities/restaurant-hours.entity.ts} | 20 ++++-- .../restaurant-hours.controller.ts | 12 ++-- .../restaurant-hours.service.ts | 18 ++--- src/restaurants/restaurants.module.ts | 8 +-- .../entity/restaurant-workshop.entity.ts | 71 +++++++++++++++++++ .../entity/workshop-worker.entity.ts | 34 +++++++++ .../restaurant-workshops.controller.ts | 0 21 files changed, 273 insertions(+), 67 deletions(-) create mode 100644 src/@core/types/general.ts create mode 100644 src/drizzle/schema/general.ts create mode 100644 src/drizzle/schema/restaurant-workshop.ts rename src/restaurants/{ => @}/controllers/restaurants.controller.ts (90%) rename src/restaurants/{ => @}/dto/create-restaurant.dto.ts (53%) create mode 100644 src/restaurants/@/dto/restaurant-with-hours.dto.ts rename src/restaurants/{ => @}/dto/update-restaurant.dto.ts (100%) rename src/restaurants/{ => @}/dto/views/get-restaurants.view.ts (67%) rename src/restaurants/{ => @}/entities/restaurant.entity.ts (97%) rename src/restaurants/{ => @}/services/restaurants.service.ts (92%) delete mode 100644 src/restaurants/dto/restaurant-with-hours.dto.ts rename src/restaurants/{dto/restaurant-hours.dto.ts => hours/entities/restaurant-hours.entity.ts} (81%) rename src/restaurants/{controllers => hours}/restaurant-hours.controller.ts (91%) rename src/restaurants/{services => hours}/restaurant-hours.service.ts (88%) create mode 100644 src/restaurants/workshops/entity/restaurant-workshop.entity.ts create mode 100644 src/restaurants/workshops/entity/workshop-worker.entity.ts create mode 100644 src/restaurants/workshops/restaurant-workshops.controller.ts diff --git a/package.json b/package.json index 9d5918a..aad54dd 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "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", - "db:push": "drizzle-kit push ", + "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/drizzle/migrate.ts", "db:clear": "node -r esbuild-register src/drizzle/clear.ts", diff --git a/src/@core/types/general.ts b/src/@core/types/general.ts new file mode 100644 index 0000000..ba5343b --- /dev/null +++ b/src/@core/types/general.ts @@ -0,0 +1,11 @@ +export enum DayOfWeekEnum { + monday = "monday", + tuesday = "tuesday", + wednesday = "wednesday", + thursday = "thursday", + friday = "friday", + saturday = "saturday", + sunday = "sunday", +} + +export type DayOfWeek = keyof typeof DayOfWeekEnum; diff --git a/src/drizzle/schema/general.ts b/src/drizzle/schema/general.ts new file mode 100644 index 0000000..5b7102a --- /dev/null +++ b/src/drizzle/schema/general.ts @@ -0,0 +1,11 @@ +import { pgEnum } from "drizzle-orm/pg-core"; + +export const dayOfWeekEnum = pgEnum("day_of_week", [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +]); diff --git a/src/drizzle/schema/restaurant-workshop.ts b/src/drizzle/schema/restaurant-workshop.ts new file mode 100644 index 0000000..1d970fe --- /dev/null +++ b/src/drizzle/schema/restaurant-workshop.ts @@ -0,0 +1,66 @@ +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("restaurantWorkshop", { + // Primary key // + id: uuid("id").defaultRandom().primaryKey(), + + // Restaurant // + restaurantId: uuid("restaurantId").notNull(), + + // Name of the workshop // + name: text("name").notNull(), + + // Is label printing enabled? // + isLabelPrintingEnabled: boolean("isLabelPrintingEnabled") + .notNull() + .default(false), + + // Is enabled? // + isEnabled: boolean("isEnabled").notNull().default(true), + + // Timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export const workshopWorkers = pgTable("workshopWorkers", { + workerId: uuid("workerId") + .notNull() + .references(() => workers.id), + workshopId: uuid("workshopId") + .notNull() + .references(() => restaurantWorkshops.id), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + +export const restaurantWorkshopRelations = relations( + restaurantWorkshops, + ({ one, many }) => ({ + restaurant: one(restaurants, { + fields: [restaurantWorkshops.restaurantId], + references: [restaurants.id], + }), + workers: many(workshopWorkers), + }), +); + +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 IRestaurantWorkshop = typeof restaurantWorkshops.$inferSelect; +export type IWorkshopWorker = typeof workshopWorkers.$inferSelect; diff --git a/src/drizzle/schema/restaurants.ts b/src/drizzle/schema/restaurants.ts index d20c827..7ac635c 100644 --- a/src/drizzle/schema/restaurants.ts +++ b/src/drizzle/schema/restaurants.ts @@ -1,3 +1,4 @@ +import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; import { relations } from "drizzle-orm"; import { boolean, @@ -9,6 +10,7 @@ import { uuid, } from "drizzle-orm/pg-core"; +import { dayOfWeekEnum } from "./general"; import { workers } from "./workers"; export const restaurants = pgTable("restaurants", { @@ -45,7 +47,7 @@ export const restaurantHours = pgTable("restaurantHours", { restaurantId: uuid("restaurantId").notNull(), // Day of the week // - dayOfWeek: text("dayOfWeek").notNull(), + dayOfWeek: dayOfWeekEnum("dayOfWeek").notNull(), // Opening and closing hours // openingTime: time("openingTime").notNull(), @@ -61,6 +63,7 @@ export const restaurantHours = pgTable("restaurantHours", { export const restaurantRelations = relations(restaurants, ({ many }) => ({ restaurantHours: many(restaurantHours), workers: many(workers), + workshops: many(restaurantWorkshops), })); export const restaurantHourRelations = relations( diff --git a/src/drizzle/schema/workers.ts b/src/drizzle/schema/workers.ts index 639576b..e93aa26 100644 --- a/src/drizzle/schema/workers.ts +++ b/src/drizzle/schema/workers.ts @@ -9,6 +9,7 @@ import { } from "drizzle-orm/pg-core"; import { z } from "zod"; +import { workshopWorkers } from "./restaurant-workshop"; import { restaurants } from "./restaurants"; import { sessions } from "./sessions"; @@ -45,9 +46,8 @@ export const workerRelations = relations(workers, ({ one, many }) => ({ fields: [workers.restaurantId], references: [restaurants.id], }), - sessions: many(sessions, { - relationName: "sessions", - }), + sessions: many(sessions), + workshops: many(workshopWorkers), })); export type IWorker = typeof workers.$inferSelect; diff --git a/src/restaurants/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts similarity index 90% rename from src/restaurants/controllers/restaurants.controller.ts rename to src/restaurants/@/controllers/restaurants.controller.ts index 55546a4..f7ac805 100644 --- a/src/restaurants/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -20,7 +20,7 @@ import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; import { RestaurantsPaginatedDto } from "../dto/views/get-restaurants.view"; -import { RestaurantDto } from "../entities/restaurant.entity"; +import { RestaurantEntity } from "../entities/restaurant.entity"; import { RestaurantsService } from "../services/restaurants.service"; @RequireSessionAuth() @@ -54,46 +54,46 @@ export class RestaurantsController { @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 { + async create(@Body() dto: CreateRestaurantDto): Promise { return await this.restaurantsService.create(dto); } @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 { + async findOne(@Param("id") id: string): Promise { return await this.restaurantsService.findById(id); } @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,7 +104,7 @@ export class RestaurantsController { async update( @Param("id") id: string, @Body() dto: UpdateRestaurantDto, - ): Promise { + ): Promise { return await this.restaurantsService.update(id, dto); } diff --git a/src/restaurants/dto/create-restaurant.dto.ts b/src/restaurants/@/dto/create-restaurant.dto.ts similarity index 53% rename from src/restaurants/dto/create-restaurant.dto.ts rename to src/restaurants/@/dto/create-restaurant.dto.ts index b6adc0d..7b8fd43 100644 --- a/src/restaurants/dto/create-restaurant.dto.ts +++ b/src/restaurants/@/dto/create-restaurant.dto.ts @@ -1,8 +1,8 @@ import { PickType } from "@nestjs/swagger"; -import { RestaurantDto } from "../entities/restaurant.entity"; +import { RestaurantEntity } from "../entities/restaurant.entity"; -export class CreateRestaurantDto extends PickType(RestaurantDto, [ +export class CreateRestaurantDto extends PickType(RestaurantEntity, [ "name", "legalEntity", "address", 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 33f5301..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 "../../entities/restaurant.entity"; +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/entities/restaurant.entity.ts b/src/restaurants/@/entities/restaurant.entity.ts similarity index 97% rename from src/restaurants/entities/restaurant.entity.ts rename to src/restaurants/@/entities/restaurant.entity.ts index ddf8980..79a49eb 100644 --- a/src/restaurants/entities/restaurant.entity.ts +++ b/src/restaurants/@/entities/restaurant.entity.ts @@ -10,7 +10,7 @@ import { IsUUID, } from "class-validator"; -export class RestaurantDto implements IRestaurant { +export class RestaurantEntity implements IRestaurant { @IsUUID() @Expose() @ApiProperty({ diff --git a/src/restaurants/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts similarity index 92% rename from src/restaurants/services/restaurants.service.ts rename to src/restaurants/@/services/restaurants.service.ts index 2b60959..9be4f4d 100644 --- a/src/restaurants/services/restaurants.service.ts +++ b/src/restaurants/@/services/restaurants.service.ts @@ -8,7 +8,7 @@ import { PG_CONNECTION } from "src/constants"; import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; -import { RestaurantDto } from "../entities/restaurant.entity"; +import { RestaurantEntity } from "../entities/restaurant.entity"; @Injectable() export class RestaurantsService { @@ -34,7 +34,7 @@ export class RestaurantsService { */ public async findMany(options: { pagination: IPagination; - }): Promise { + }): Promise { return await this.pg.query.restaurants.findMany({ limit: options.pagination.size, offset: options.pagination.offset, @@ -46,7 +46,7 @@ export class RestaurantsService { * @param id * @returns */ - public async findById(id: string): Promise { + public async findById(id: string): Promise { const data = await this.pg.query.restaurants.findFirst({ where: eq(schema.restaurants.id, id), }); @@ -63,7 +63,7 @@ export class RestaurantsService { * @param dto * @returns */ - public async create(dto: CreateRestaurantDto): Promise { + public async create(dto: CreateRestaurantDto): Promise { const data = await this.pg .insert(schema.restaurants) .values(dto) @@ -83,7 +83,7 @@ export class RestaurantsService { public async update( id: string, dto: UpdateRestaurantDto, - ): Promise { + ): Promise { // Disable restaurant if it is closed forever if (dto.isClosedForever) { dto.isEnabled = false; 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 05a8f15..0000000 --- a/src/restaurants/dto/restaurant-with-hours.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { Expose, Type } from "class-transformer"; - -import { RestaurantDto } from "../entities/restaurant.entity"; - -import { RestaurantHoursDto } from "./restaurant-hours.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 81% rename from src/restaurants/dto/restaurant-hours.dto.ts rename to src/restaurants/hours/entities/restaurant-hours.entity.ts index ec14dcd..059f89c 100644 --- a/src/restaurants/dto/restaurant-hours.dto.ts +++ b/src/restaurants/hours/entities/restaurant-hours.entity.ts @@ -1,9 +1,16 @@ +import { DayOfWeek, DayOfWeekEnum } from "@core/types/general"; import { ApiProperty, OmitType, PartialType, PickType } from "@nestjs/swagger"; import { IRestaurantHours } from "@postgress-db/schema/restaurants"; import { Expose } from "class-transformer"; -import { IsBoolean, IsISO8601, IsString, IsUUID } from "class-validator"; +import { + IsBoolean, + IsEnum, + IsISO8601, + IsString, + IsUUID, +} from "class-validator"; -export class RestaurantHoursDto implements IRestaurantHours { +export class RestaurantHoursEntity implements IRestaurantHours { @IsUUID() @Expose() @ApiProperty({ @@ -20,13 +27,14 @@ 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() @Expose() @@ -69,7 +77,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 91% rename from src/restaurants/controllers/restaurant-hours.controller.ts rename to src/restaurants/hours/restaurant-hours.controller.ts index a0f55d5..1d2ed0d 100644 --- a/src/restaurants/controllers/restaurant-hours.controller.ts +++ b/src/restaurants/hours/restaurant-hours.controller.ts @@ -13,10 +13,10 @@ import { RequireSessionAuth } from "src/auth/decorators/session-auth.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, @@ -33,11 +33,11 @@ export class RestaurantHoursController { ) {} @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); @@ -64,7 +64,7 @@ export class RestaurantHoursController { @Put(":hoursId") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") - @Serializable(RestaurantHoursDto) + @Serializable(RestaurantHoursEntity) @ApiOperation({ summary: "Updates restaurant hours" }) @ApiOkResponse({ description: "Restaurant hours have been successfully updated", diff --git a/src/restaurants/services/restaurant-hours.service.ts b/src/restaurants/hours/restaurant-hours.service.ts similarity index 88% rename from src/restaurants/services/restaurant-hours.service.ts rename to src/restaurants/hours/restaurant-hours.service.ts index a01e69b..c58e445 100644 --- a/src/restaurants/services/restaurant-hours.service.ts +++ b/src/restaurants/hours/restaurant-hours.service.ts @@ -5,13 +5,13 @@ 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,7 +36,9 @@ 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`, @@ -53,7 +55,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,7 +68,7 @@ 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`, @@ -90,7 +92,7 @@ export class RestaurantHoursService { public async update( id: string, dto: UpdateRestaurantHoursDto, - ): Promise { + ): Promise { if (!(await this.restaurantsService.isExists(id))) { throw new BadRequestException(`Restaurant with id ${id} not found`); } diff --git a/src/restaurants/restaurants.module.ts b/src/restaurants/restaurants.module.ts index 442d60b..68e854b 100644 --- a/src/restaurants/restaurants.module.ts +++ b/src/restaurants/restaurants.module.ts @@ -1,10 +1,10 @@ import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.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 { RestaurantHoursController } from "./hours/restaurant-hours.controller"; +import { RestaurantsController } from "./@/controllers/restaurants.controller"; +import { RestaurantHoursService } from "./hours/restaurant-hours.service"; +import { RestaurantsService } from "./@/services/restaurants.service"; @Module({ imports: [DrizzleModule], 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..6d6a675 --- /dev/null +++ b/src/restaurants/workshops/entity/restaurant-workshop.entity.ts @@ -0,0 +1,71 @@ +import { ApiProperty, OmitType, PartialType } from "@nestjs/swagger"; +import { IRestaurantWorkshop } from "@postgress-db/schema/restaurant-workshop"; +import { Expose } from "class-transformer"; +import { IsBoolean, IsISO8601, IsString, IsUUID } from "class-validator"; + +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..0f0abb0 --- /dev/null +++ b/src/restaurants/workshops/entity/workshop-worker.entity.ts @@ -0,0 +1,34 @@ +import { ApiProperty, OmitType } from "@nestjs/swagger"; +import { IWorkshopWorker } from "@postgress-db/schema/restaurant-workshop"; +import { Expose } from "class-transformer"; +import { IsISO8601, IsUUID } from "class-validator"; + +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..e69de29 From 37d3a4406ef1910524712f3d2aea6f39c40ad555 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 6 Jan 2025 18:01:38 +0200 Subject: [PATCH 008/180] fear: crud for restaurant workshops --- src/drizzle/drizzle.module.ts | 2 + src/restaurants/restaurants.module.ts | 24 +++- .../restaurant-workshops.controller.ts | 99 +++++++++++++ .../workshops/restaurant-workshops.service.ts | 134 ++++++++++++++++++ 4 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 src/restaurants/workshops/restaurant-workshops.service.ts diff --git a/src/drizzle/drizzle.module.ts b/src/drizzle/drizzle.module.ts index 63bd223..f332683 100644 --- a/src/drizzle/drizzle.module.ts +++ b/src/drizzle/drizzle.module.ts @@ -5,6 +5,7 @@ import { Pool } from "pg"; import { PG_CONNECTION } from "../constants"; +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"; @@ -13,6 +14,7 @@ export const schema = { ...restaurants, ...sessions, ...workers, + ...restaurantWorkshops, }; export type Schema = typeof schema; diff --git a/src/restaurants/restaurants.module.ts b/src/restaurants/restaurants.module.ts index 68e854b..9a7687e 100644 --- a/src/restaurants/restaurants.module.ts +++ b/src/restaurants/restaurants.module.ts @@ -1,15 +1,29 @@ import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.module"; -import { RestaurantHoursController } from "./hours/restaurant-hours.controller"; import { RestaurantsController } from "./@/controllers/restaurants.controller"; -import { RestaurantHoursService } from "./hours/restaurant-hours.service"; import { RestaurantsService } from "./@/services/restaurants.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], + providers: [ + RestaurantsService, + RestaurantHoursService, + RestaurantWorkshopsService, + ], + controllers: [ + RestaurantsController, + RestaurantHoursController, + RestaurantWorkshopsController, + ], + exports: [ + RestaurantsService, + RestaurantHoursService, + RestaurantWorkshopsService, + ], }) export class RestaurantsModule {} diff --git a/src/restaurants/workshops/restaurant-workshops.controller.ts b/src/restaurants/workshops/restaurant-workshops.controller.ts index e69de29..21a8651 100644 --- a/src/restaurants/workshops/restaurant-workshops.controller.ts +++ b/src/restaurants/workshops/restaurant-workshops.controller.ts @@ -0,0 +1,99 @@ +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 { + ApiCreatedResponse, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + OmitType, +} from "@nestjs/swagger"; +import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; + +import { + CreateRestaurantWorkshopDto, + RestaurantWorkshopDto, + UpdateRestaurantWorkshopDto, +} from "./entity/restaurant-workshop.entity"; +import { RestaurantWorkshopsService } from "./restaurant-workshops.service"; + +export class CreateRestaurantWorkshopPayloadDto extends OmitType( + CreateRestaurantWorkshopDto, + ["restaurantId"] as const, +) {} + +@RequireSessionAuth() +@Controller("restaurants/:id/workshops", { + tags: ["restaurants"], +}) +export class RestaurantWorkshopsController { + constructor( + private readonly restaurantWorkshopsService: RestaurantWorkshopsService, + ) {} + + @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); + } + + @Post() + @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") + @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, + }); + } + + @Put(":workshopId") + @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") + @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); + } + + @Delete(":workshopId") + @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") + @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..60cf23d --- /dev/null +++ b/src/restaurants/workshops/restaurant-workshops.service.ts @@ -0,0 +1,134 @@ +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; +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 { + 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( + `Restaurant with id ${restaurantId} not found`, + ); + } + + 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( + `Restaurant with id ${dto.restaurantId} not found`, + ); + } + + 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( + `Restaurant workshop with id ${id} not found`, + ); + } + + 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( + `Restaurant workshop with id ${id} not found`, + ); + } + + const result = await this.pg + .delete(restaurantWorkshops) + .where(eq(restaurantWorkshops.id, id)) + .returning(); + + return { id: result[0].id }; + } +} From 25dee39402a697b30214f913a81b611e7a63c3c2 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 7 Jan 2025 16:02:38 +0200 Subject: [PATCH 009/180] fix: bugs and time decorator --- .../decorators/is-time-format.decorator.ts | 28 +++++++++++++++++++ src/drizzle/schema/restaurants.ts | 5 ++-- .../hours/entities/restaurant-hours.entity.ts | 9 ++++-- .../hours/restaurant-hours.service.ts | 16 +++++++++-- 4 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 src/@core/decorators/is-time-format.decorator.ts 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..95851d9 --- /dev/null +++ b/src/@core/decorators/is-time-format.decorator.ts @@ -0,0 +1,28 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from "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/drizzle/schema/restaurants.ts b/src/drizzle/schema/restaurants.ts index 7ac635c..126a581 100644 --- a/src/drizzle/schema/restaurants.ts +++ b/src/drizzle/schema/restaurants.ts @@ -5,7 +5,6 @@ import { numeric, pgTable, text, - time, timestamp, uuid, } from "drizzle-orm/pg-core"; @@ -50,8 +49,8 @@ export const restaurantHours = pgTable("restaurantHours", { dayOfWeek: dayOfWeekEnum("dayOfWeek").notNull(), // Opening and closing hours // - openingTime: time("openingTime").notNull(), - closingTime: time("closingTime").notNull(), + openingTime: text("openingTime").notNull(), + closingTime: text("closingTime").notNull(), isEnabled: boolean("isEnabled").notNull().default(true), diff --git a/src/restaurants/hours/entities/restaurant-hours.entity.ts b/src/restaurants/hours/entities/restaurant-hours.entity.ts index 059f89c..ea6d362 100644 --- a/src/restaurants/hours/entities/restaurant-hours.entity.ts +++ b/src/restaurants/hours/entities/restaurant-hours.entity.ts @@ -1,3 +1,4 @@ +import { IsTimeFormat } from "@core/decorators/is-time-format.decorator"; import { DayOfWeek, DayOfWeekEnum } from "@core/types/general"; import { ApiProperty, OmitType, PartialType, PickType } from "@nestjs/swagger"; import { IRestaurantHours } from "@postgress-db/schema/restaurants"; @@ -37,18 +38,22 @@ export class RestaurantHoursEntity implements IRestaurantHours { 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; diff --git a/src/restaurants/hours/restaurant-hours.service.ts b/src/restaurants/hours/restaurant-hours.service.ts index c58e445..d3f8df5 100644 --- a/src/restaurants/hours/restaurant-hours.service.ts +++ b/src/restaurants/hours/restaurant-hours.service.ts @@ -47,6 +47,7 @@ export class RestaurantHoursService { return await this.pg.query.restaurantHours.findMany({ where: eq(schema.restaurantHours.restaurantId, restaurantId), + orderBy: schema.restaurantHours.dayOfWeek, }); } @@ -75,6 +76,17 @@ export class RestaurantHoursService { ); } + // 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) @@ -93,8 +105,8 @@ export class RestaurantHoursService { id: string, dto: UpdateRestaurantHoursDto, ): Promise { - if (!(await this.restaurantsService.isExists(id))) { - throw new BadRequestException(`Restaurant with id ${id} not found`); + if (!(await this.isExists(id))) { + throw new BadRequestException(`Hour record with id ${id} not found`); } const data = await this.pg From ef6f5052ddbcb8fc4762644af788a3cdde642266 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 7 Jan 2025 17:07:41 +0200 Subject: [PATCH 010/180] feat: attach restaurantName for worker entity --- src/workers/entities/worker.entity.ts | 10 ++++ src/workers/workers.service.ts | 84 ++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/workers/entities/worker.entity.ts b/src/workers/entities/worker.entity.ts index a112499..08e2842 100644 --- a/src/workers/entities/worker.entity.ts +++ b/src/workers/entities/worker.entity.ts @@ -37,6 +37,16 @@ export class WorkerEntity implements IWorker { }) restaurantId: string | null; + @IsOptional() + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the restaurant where worker is employed", + example: "Restaurant Name", + type: String, + }) + restaurantName: string | null; + @IsString() @MinLength(4) @Expose() diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index 69d9ca5..5403a58 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -47,7 +47,27 @@ export class WorkersService { }): Promise { const { pagination, sorting, filters } = options; - const query = this.pg.select().from(schema.workers); + const query = this.pg + .select({ + id: schema.workers.id, + name: schema.workers.name, + login: schema.workers.login, + role: schema.workers.role, + isBlocked: schema.workers.isBlocked, + hiredAt: schema.workers.hiredAt, + firedAt: schema.workers.firedAt, + onlineAt: schema.workers.onlineAt, + createdAt: schema.workers.createdAt, + updatedAt: schema.workers.updatedAt, + restaurantId: schema.workers.restaurantId, + restaurantName: schema.restaurants.name, + passwordHash: schema.workers.passwordHash, + }) + .from(schema.workers) + .leftJoin( + schema.restaurants, + eq(schema.workers.restaurantId, schema.restaurants.id), + ); if (filters) { query.where(DrizzleUtils.buildFilterConditions(schema.workers, filters)); @@ -69,10 +89,32 @@ 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 result = await this.pg + .select({ + id: schema.workers.id, + name: schema.workers.name, + login: schema.workers.login, + role: schema.workers.role, + isBlocked: schema.workers.isBlocked, + hiredAt: schema.workers.hiredAt, + firedAt: schema.workers.firedAt, + onlineAt: schema.workers.onlineAt, + createdAt: schema.workers.createdAt, + updatedAt: schema.workers.updatedAt, + restaurantId: schema.workers.restaurantId, + restaurantName: schema.restaurants.name, + passwordHash: schema.workers.passwordHash, + }) + .from(schema.workers) + .leftJoin( + schema.restaurants, + eq(schema.workers.restaurantId, schema.restaurants.id), + ) + .where(eq(schema.workers.id, id)) + .limit(1); + + return result[0]; } /** @@ -80,10 +122,34 @@ 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 result = await this.pg + .select({ + id: schema.workers.id, + name: schema.workers.name, + login: schema.workers.login, + role: schema.workers.role, + isBlocked: schema.workers.isBlocked, + hiredAt: schema.workers.hiredAt, + firedAt: schema.workers.firedAt, + onlineAt: schema.workers.onlineAt, + createdAt: schema.workers.createdAt, + updatedAt: schema.workers.updatedAt, + restaurantId: schema.workers.restaurantId, + restaurantName: schema.restaurants.name, + passwordHash: schema.workers.passwordHash, + }) + .from(schema.workers) + .leftJoin( + schema.restaurants, + eq(schema.workers.restaurantId, schema.restaurants.id), + ) + .where(eq(schema.workers.login, value)) + .limit(1); + + return result[0]; } /** From 8b9e34243b4b576c3f5f950eb5005189d8109a55 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 7 Jan 2025 17:46:24 +0200 Subject: [PATCH 011/180] feat: asignee workers to the workshops --- .../put-restaurant-workshop-workers.dto.ts | 13 ++++ .../restaurant-workshop-worker.entity.ts | 33 ++++++++++ .../restaurant-workshops.controller.ts | 30 +++++++++ .../workshops/restaurant-workshops.service.ts | 65 ++++++++++++++++++- 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts create mode 100644 src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts 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..e5a1f8c --- /dev/null +++ b/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsUUID } from "class-validator"; + +export class UpdateRestaurantWorkshopWorkersDto { + @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..042d92f --- /dev/null +++ b/src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { workerRoleEnum } 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: workerRoleEnum.enumValues, + }) + role: (typeof workerRoleEnum.enumValues)[number]; +} diff --git a/src/restaurants/workshops/restaurant-workshops.controller.ts b/src/restaurants/workshops/restaurant-workshops.controller.ts index 21a8651..8575a10 100644 --- a/src/restaurants/workshops/restaurant-workshops.controller.ts +++ b/src/restaurants/workshops/restaurant-workshops.controller.ts @@ -11,6 +11,8 @@ import { } from "@nestjs/swagger"; import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; +import { UpdateRestaurantWorkshopWorkersDto } from "./dto/put-restaurant-workshop-workers.dto"; +import { WorkshopWorkerEntity } from "./entity/restaurant-workshop-worker.entity"; import { CreateRestaurantWorkshopDto, RestaurantWorkshopDto, @@ -79,6 +81,34 @@ export class RestaurantWorkshopsController { return await this.restaurantWorkshopsService.update(id, dto); } + @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); + } + + @Put(":workshopId/workers") + @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") + @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; + } + @Delete(":workshopId") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Deletes restaurant workshop" }) diff --git a/src/restaurants/workshops/restaurant-workshops.service.ts b/src/restaurants/workshops/restaurant-workshops.service.ts index 60cf23d..1878d0f 100644 --- a/src/restaurants/workshops/restaurant-workshops.service.ts +++ b/src/restaurants/workshops/restaurant-workshops.service.ts @@ -1,13 +1,18 @@ import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; import { Inject, Injectable } from "@nestjs/common"; import { schema } from "@postgress-db/drizzle.module"; -import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; +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, @@ -131,4 +136,62 @@ export class RestaurantWorkshopsService { 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( + `Restaurant workshop with id ${workshopId} not found`, + ); + } + + 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( + `Restaurant workshop with id ${workshopId} not found`, + ); + } + + // 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, + })), + ); + } + } } From 3bfa26544f4f981899e28342ac81733d5e0ed632 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 7 Jan 2025 18:10:11 +0200 Subject: [PATCH 012/180] fix: dto --- .../workshops/dto/put-restaurant-workshop-workers.dto.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts b/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts index e5a1f8c..196b8d8 100644 --- a/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts +++ b/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts @@ -1,7 +1,9 @@ import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; import { IsArray, IsUUID } from "class-validator"; export class UpdateRestaurantWorkshopWorkersDto { + @Expose() @IsArray() @IsUUID(4, { each: true }) @ApiProperty({ From f6bdd17dfd5d4baa349c82ab508c53db31eb8a12 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 8 Jan 2025 16:05:33 +0200 Subject: [PATCH 013/180] feat: timezones list api endpoint --- package.json | 3 ++ src/app.module.ts | 2 + .../entities/timezones-list.entity.ts | 17 ++++++++ src/timezones/timezones.controller.ts | 39 +++++++++++++++++++ src/timezones/timezones.module.ts | 11 ++++++ src/timezones/timezones.service.ts | 17 ++++++++ yarn.lock | 15 +++++++ 7 files changed, 104 insertions(+) create mode 100644 src/timezones/entities/timezones-list.entity.ts create mode 100644 src/timezones/timezones.controller.ts create mode 100644 src/timezones/timezones.module.ts create mode 100644 src/timezones/timezones.service.ts diff --git a/package.json b/package.json index aad54dd..4401f00 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,14 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.2.0", "@nestjs/throttler": "^5.1.1", + "@vvo/tzdb": "^6.157.0", "argon2": "^0.31.2", "axios": "^1.7.9", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "drizzle-orm": "0.38.3", "drizzle-zod": "0.6.1", "eslint-plugin-import": "^2.29.1", diff --git a/src/app.module.ts b/src/app.module.ts index 35d1aef..69bb580 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { MongooseModule } from "@nestjs/mongoose"; import { ThrottlerModule } from "@nestjs/throttler"; import { ZodValidationPipe } from "nestjs-zod"; import { AddressesModule } from "src/addresses/addresses.module"; +import { TimezonesModule } from "src/timezones/timezones.module"; import { AuthModule } from "./auth/auth.module"; import { SessionAuthGuard } from "./auth/guards/session-auth.guard"; @@ -40,6 +41,7 @@ import { WorkersModule } from "./workers/workers.module"; url: process.env.REDIS_URL, }, }), + TimezonesModule, AuthModule, WorkersModule, RestaurantsModule, diff --git a/src/timezones/entities/timezones-list.entity.ts b/src/timezones/entities/timezones-list.entity.ts new file mode 100644 index 0000000..39b792d --- /dev/null +++ b/src/timezones/entities/timezones-list.entity.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsArray, IsString } from "class-validator"; + +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..714dfd4 --- /dev/null +++ b/src/timezones/timezones.controller.ts @@ -0,0 +1,39 @@ +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 { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; +import { TimezonesListEntity } from "src/timezones/entities/timezones-list.entity"; +import { TimezonesService } from "src/timezones/timezones.service"; + +@RequireSessionAuth() +@Controller("timezones", { + tags: ["Timezones"], +}) +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class TimezonesController { + constructor(private readonly timezonesService: TimezonesService) {} + + @Get("list") + @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..7863e05 --- /dev/null +++ b/src/timezones/timezones.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from "@nestjs/common"; +import { getTimeZones } from "@vvo/tzdb"; +// import { formatISO } from "date-fns"; +// import { utcToZonedTime } from "date-fns-tz"; + +@Injectable() +export class TimezonesService { + getAllTimezones(): string[] { + // return Intl.supportedValuesOf("timeZone"); + + const timezones = getTimeZones({ + includeUtc: true, + }).filter((tz) => tz.continentCode === "EU" || tz.countryCode === "RU"); + + return timezones.map((tz) => tz.name); + } +} diff --git a/yarn.lock b/yarn.lock index 36372af..69489be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1590,6 +1590,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" @@ -2593,6 +2598,16 @@ crypt@0.0.2: resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow== +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" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" From 416d49bd033ffbca10b1ccbc08eebdc206f815ff Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 8 Jan 2025 16:24:44 +0200 Subject: [PATCH 014/180] feat: restaurant timezone assignment --- src/drizzle/schema/restaurants.ts | 3 +++ src/restaurants/@/dto/create-restaurant.dto.ts | 1 + src/restaurants/@/entities/restaurant.entity.ts | 8 ++++++++ src/restaurants/@/services/restaurants.service.ts | 15 +++++++++++++++ src/restaurants/restaurants.module.ts | 3 ++- src/timezones/timezones.controller.ts | 2 +- src/timezones/timezones.service.ts | 6 ++++++ 7 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/drizzle/schema/restaurants.ts b/src/drizzle/schema/restaurants.ts index 126a581..94dc9d6 100644 --- a/src/drizzle/schema/restaurants.ts +++ b/src/drizzle/schema/restaurants.ts @@ -27,6 +27,9 @@ export const restaurants = pgTable("restaurants", { latitude: numeric("latitude").notNull(), longitude: numeric("longitude").notNull(), + // Timezone of the restaurant // + timezone: text("timezone").notNull().default("Europe/Tallinn"), + // Is the restaurant enabled? // isEnabled: boolean("isEnabled").notNull().default(false), diff --git a/src/restaurants/@/dto/create-restaurant.dto.ts b/src/restaurants/@/dto/create-restaurant.dto.ts index 7b8fd43..1629656 100644 --- a/src/restaurants/@/dto/create-restaurant.dto.ts +++ b/src/restaurants/@/dto/create-restaurant.dto.ts @@ -8,6 +8,7 @@ export class CreateRestaurantDto extends PickType(RestaurantEntity, [ "address", "latitude", "longitude", + "timezone", "isEnabled", "isClosedForever", ]) {} diff --git a/src/restaurants/@/entities/restaurant.entity.ts b/src/restaurants/@/entities/restaurant.entity.ts index 79a49eb..5ac0297 100644 --- a/src/restaurants/@/entities/restaurant.entity.ts +++ b/src/restaurants/@/entities/restaurant.entity.ts @@ -63,6 +63,14 @@ export class RestaurantEntity implements IRestaurant { }) longitude: string; + @Expose() + @IsString() + @ApiProperty({ + description: "Timezone of the restaurant", + example: "Europe/Tallinn", + }) + timezone: string; + @IsBoolean() @Expose() @ApiProperty({ diff --git a/src/restaurants/@/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts index 9be4f4d..6fb53d4 100644 --- a/src/restaurants/@/services/restaurants.service.ts +++ b/src/restaurants/@/services/restaurants.service.ts @@ -1,10 +1,12 @@ 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 { Inject, Injectable } from "@nestjs/common"; import { schema } from "@postgress-db/drizzle.module"; import { count, eq } 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"; @@ -14,6 +16,7 @@ import { RestaurantEntity } from "../entities/restaurant.entity"; export class RestaurantsService { constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly timezonesService: TimezonesService, ) {} /** @@ -64,6 +67,12 @@ export class RestaurantsService { * @returns */ public async create(dto: CreateRestaurantDto): Promise { + if (dto.timezone && !this.timezonesService.checkTimezone(dto.timezone)) { + throw new BadRequestException({ + title: "Provided timezone can't be set", + }); + } + const data = await this.pg .insert(schema.restaurants) .values(dto) @@ -84,6 +93,12 @@ export class RestaurantsService { id: string, dto: UpdateRestaurantDto, ): Promise { + if (dto.timezone && !this.timezonesService.checkTimezone(dto.timezone)) { + throw new BadRequestException({ + title: "Provided timezone can't be set", + }); + } + // Disable restaurant if it is closed forever if (dto.isClosedForever) { dto.isEnabled = false; diff --git a/src/restaurants/restaurants.module.ts b/src/restaurants/restaurants.module.ts index 9a7687e..f84f4b4 100644 --- a/src/restaurants/restaurants.module.ts +++ b/src/restaurants/restaurants.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { TimezonesModule } from "src/timezones/timezones.module"; import { RestaurantsController } from "./@/controllers/restaurants.controller"; import { RestaurantsService } from "./@/services/restaurants.service"; @@ -9,7 +10,7 @@ import { RestaurantWorkshopsController } from "./workshops/restaurant-workshops. import { RestaurantWorkshopsService } from "./workshops/restaurant-workshops.service"; @Module({ - imports: [DrizzleModule], + imports: [DrizzleModule, TimezonesModule], providers: [ RestaurantsService, RestaurantHoursService, diff --git a/src/timezones/timezones.controller.ts b/src/timezones/timezones.controller.ts index 714dfd4..b761741 100644 --- a/src/timezones/timezones.controller.ts +++ b/src/timezones/timezones.controller.ts @@ -20,7 +20,7 @@ import { TimezonesService } from "src/timezones/timezones.service"; export class TimezonesController { constructor(private readonly timezonesService: TimezonesService) {} - @Get("list") + @Get() @ApiOperation({ summary: "Get list of timezones", }) diff --git a/src/timezones/timezones.service.ts b/src/timezones/timezones.service.ts index 7863e05..a42a5f2 100644 --- a/src/timezones/timezones.service.ts +++ b/src/timezones/timezones.service.ts @@ -14,4 +14,10 @@ export class TimezonesService { return timezones.map((tz) => tz.name); } + + checkTimezone(timezone: string): boolean { + const timezones = this.getAllTimezones(); + + return timezones.includes(timezone); + } } From e6e9c906e1cb1e28e2779bd4ac904073a20b9fe0 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 8 Jan 2025 17:45:50 +0200 Subject: [PATCH 015/180] feat: guests drizzle model --- src/drizzle/drizzle.module.ts | 2 ++ src/drizzle/schema/guests.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 src/drizzle/schema/guests.ts diff --git a/src/drizzle/drizzle.module.ts b/src/drizzle/drizzle.module.ts index f332683..f3ffa05 100644 --- a/src/drizzle/drizzle.module.ts +++ b/src/drizzle/drizzle.module.ts @@ -5,6 +5,7 @@ import { Pool } from "pg"; import { PG_CONNECTION } from "../constants"; +import * as guests from "./schema/guests"; import * as restaurantWorkshops from "./schema/restaurant-workshop"; import * as restaurants from "./schema/restaurants"; import * as sessions from "./schema/sessions"; @@ -15,6 +16,7 @@ export const schema = { ...sessions, ...workers, ...restaurantWorkshops, + ...guests, }; export type Schema = typeof schema; diff --git a/src/drizzle/schema/guests.ts b/src/drizzle/schema/guests.ts new file mode 100644 index 0000000..967f241 --- /dev/null +++ b/src/drizzle/schema/guests.ts @@ -0,0 +1,14 @@ +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("bonusBalance").notNull().default(0), + lastVisitAt: timestamp("onlineAt").notNull().defaultNow(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export type IGuest = typeof guests.$inferSelect; From 5eea01b2596f481f669b238ab522ac93d7811554 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 8 Jan 2025 18:06:29 +0200 Subject: [PATCH 016/180] feat: drizzle dishes model --- src/drizzle/drizzle.module.ts | 2 ++ src/drizzle/schema/dishes.ts | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/drizzle/schema/dishes.ts diff --git a/src/drizzle/drizzle.module.ts b/src/drizzle/drizzle.module.ts index f3ffa05..a166137 100644 --- a/src/drizzle/drizzle.module.ts +++ b/src/drizzle/drizzle.module.ts @@ -5,6 +5,7 @@ import { Pool } from "pg"; import { PG_CONNECTION } from "../constants"; +import * as dishes from "./schema/dishes"; import * as guests from "./schema/guests"; import * as restaurantWorkshops from "./schema/restaurant-workshop"; import * as restaurants from "./schema/restaurants"; @@ -17,6 +18,7 @@ export const schema = { ...workers, ...restaurantWorkshops, ...guests, + ...dishes, }; export type Schema = typeof schema; diff --git a/src/drizzle/schema/dishes.ts b/src/drizzle/schema/dishes.ts new file mode 100644 index 0000000..34d8880 --- /dev/null +++ b/src/drizzle/schema/dishes.ts @@ -0,0 +1,50 @@ +import { + boolean, + integer, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +export const weightMeasureEnum = pgEnum("weightMeasureEnum", [ + "grams", + "milliliters", +]); + +export const dishes = pgTable("dishes", { + id: uuid("id").defaultRandom().primaryKey(), + + // 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("cookingTimeInMin").notNull().default(0), + + // How many pcs in one item (for example: 6 hinkali per one item) // + amountPerItem: integer("amountPerItem").notNull().default(1), + + // Weight of the dish // + weight: integer("weight").notNull().default(0), + weightMeasure: weightMeasureEnum("weightMeasure").notNull().default("grams"), + + // Label printing // + isLabelPrintingEnabled: boolean("isLabelPrintingEnabled") + .notNull() + .default(false), + printLabelEveryItem: integer("printLabelEveryItem").notNull().default(0), + + // Publishing booleans // + isPublishedInApp: boolean("isPublishedInApp").notNull().default(false), + isPublishedAtSite: boolean("isPublishedAtSite").notNull().default(false), + + // Default timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export type IDish = typeof dishes.$inferSelect; From f040134a4ebcf881bc84a7bf62a43ab28ae6d5e2 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 8 Jan 2025 18:33:39 +0200 Subject: [PATCH 017/180] feat: dishes categories drizzle model with many-to-many --- src/drizzle/drizzle.module.ts | 2 ++ src/drizzle/schema/dish-categories.ts | 40 +++++++++++++++++++++++++++ src/drizzle/schema/dishes.ts | 6 ++++ src/drizzle/schema/many-to-many.ts | 32 +++++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 src/drizzle/schema/dish-categories.ts create mode 100644 src/drizzle/schema/many-to-many.ts diff --git a/src/drizzle/drizzle.module.ts b/src/drizzle/drizzle.module.ts index a166137..7ff6abe 100644 --- a/src/drizzle/drizzle.module.ts +++ b/src/drizzle/drizzle.module.ts @@ -5,6 +5,7 @@ import { Pool } from "pg"; import { PG_CONNECTION } from "../constants"; +import * as dishCategories from "./schema/dish-categories"; import * as dishes from "./schema/dishes"; import * as guests from "./schema/guests"; import * as restaurantWorkshops from "./schema/restaurant-workshop"; @@ -19,6 +20,7 @@ export const schema = { ...restaurantWorkshops, ...guests, ...dishes, + ...dishCategories, }; export type Schema = typeof schema; diff --git a/src/drizzle/schema/dish-categories.ts b/src/drizzle/schema/dish-categories.ts new file mode 100644 index 0000000..e4bab41 --- /dev/null +++ b/src/drizzle/schema/dish-categories.ts @@ -0,0 +1,40 @@ +import { dishesToCategories } from "@postgress-db/schema/many-to-many"; +import { relations, sql } from "drizzle-orm"; +import { + boolean, + integer, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +export const dishCategories = pgTable("dishCategories", { + id: uuid("id").defaultRandom().primaryKey(), + + // Name of the category // + name: text("name").notNull().default(""), + + // Will category be visible for workers // + showForWorkers: boolean("showForWorkers").notNull().default(false), + + // Will category be visible for guests at site and in app // + showForGuests: boolean("showForGuests").notNull().default(false), + + // Sorting index in the admin menu // + sortIndex: integer("sortIndex") + .notNull() + .default( + sql`nextval(pg_get_serial_sequence('dishCategories', 'sortIndex'))`, + ), + + // Default timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export type IDishCategory = typeof dishCategories.$inferSelect; + +export const dishCategoryRelations = relations(dishCategories, ({ many }) => ({ + dishesToCategories: many(dishesToCategories), +})); diff --git a/src/drizzle/schema/dishes.ts b/src/drizzle/schema/dishes.ts index 34d8880..a58e107 100644 --- a/src/drizzle/schema/dishes.ts +++ b/src/drizzle/schema/dishes.ts @@ -1,3 +1,5 @@ +import { dishesToCategories } from "@postgress-db/schema/many-to-many"; +import { relations } from "drizzle-orm"; import { boolean, integer, @@ -48,3 +50,7 @@ export const dishes = pgTable("dishes", { }); export type IDish = typeof dishes.$inferSelect; + +export const dishRelations = relations(dishes, ({ many }) => ({ + dishesToCategories: many(dishesToCategories), +})); diff --git a/src/drizzle/schema/many-to-many.ts b/src/drizzle/schema/many-to-many.ts new file mode 100644 index 0000000..77196cc --- /dev/null +++ b/src/drizzle/schema/many-to-many.ts @@ -0,0 +1,32 @@ +import { dishCategories } from "@postgress-db/schema/dish-categories"; +import { dishes } from "@postgress-db/schema/dishes"; +import { relations } from "drizzle-orm"; +import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"; + +// Dishes to dish categories relation // +export const dishesToCategories = pgTable( + "dishesToCategories", + { + dishId: uuid("dishId").notNull(), + dishCategoryId: uuid("dishCategoryId").notNull(), + }, + (t) => [ + primaryKey({ + columns: [t.dishId, t.dishCategoryId], + }), + ], +); + +export const dishesToCategoriesRelations = relations( + dishesToCategories, + ({ one }) => ({ + dish: one(dishes, { + fields: [dishesToCategories.dishId], + references: [dishes.id], + }), + dishCategory: one(dishCategories, { + fields: [dishesToCategories.dishCategoryId], + references: [dishCategories.id], + }), + }), +); From c55d1abc0ffa2b9de4c58f6abc8f8f86dbc6ea30 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 8 Jan 2025 18:54:05 +0200 Subject: [PATCH 018/180] feat: init of the crud for guests --- src/addresses/addresses.controller.ts | 4 +- src/app.module.ts | 2 + src/drizzle/drizzle.module.ts | 2 + src/guests/entities/guest.entity.ts | 75 +++++++++++++++++++ .../entities/guests-paginated.entity.ts | 14 ++++ src/guests/guests.controller.ts | 59 +++++++++++++++ src/guests/guests.module.ts | 12 +++ src/guests/guests.service.ts | 60 +++++++++++++++ src/main.ts | 1 + src/timezones/timezones.controller.ts | 4 +- src/workers/entities/worker.entity.ts | 7 +- 11 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 src/guests/entities/guest.entity.ts create mode 100644 src/guests/entities/guests-paginated.entity.ts create mode 100644 src/guests/guests.controller.ts create mode 100644 src/guests/guests.module.ts create mode 100644 src/guests/guests.service.ts diff --git a/src/addresses/addresses.controller.ts b/src/addresses/addresses.controller.ts index fa82048..3cd38d5 100644 --- a/src/addresses/addresses.controller.ts +++ b/src/addresses/addresses.controller.ts @@ -14,9 +14,7 @@ import { AddressSuggestion } from "./entities/suggestion.entity"; import { AddressesService } from "./services/addresses.service"; @RequireSessionAuth() -@Controller("addresses", { - tags: ["Addresses"], -}) +@Controller("addresses") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) export class AddressesController { diff --git a/src/app.module.ts b/src/app.module.ts index 69bb580..3171a29 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { MongooseModule } from "@nestjs/mongoose"; import { ThrottlerModule } from "@nestjs/throttler"; import { ZodValidationPipe } from "nestjs-zod"; import { AddressesModule } from "src/addresses/addresses.module"; +import { GuestsModule } from "src/guests/guests.module"; import { TimezonesModule } from "src/timezones/timezones.module"; import { AuthModule } from "./auth/auth.module"; @@ -46,6 +47,7 @@ import { WorkersModule } from "./workers/workers.module"; WorkersModule, RestaurantsModule, AddressesModule, + GuestsModule, ], providers: [ { diff --git a/src/drizzle/drizzle.module.ts b/src/drizzle/drizzle.module.ts index 7ff6abe..6a709b6 100644 --- a/src/drizzle/drizzle.module.ts +++ b/src/drizzle/drizzle.module.ts @@ -8,6 +8,7 @@ import { PG_CONNECTION } from "../constants"; import * as dishCategories from "./schema/dish-categories"; import * as dishes from "./schema/dishes"; import * as guests from "./schema/guests"; +import * as manyToMany from "./schema/many-to-many"; import * as restaurantWorkshops from "./schema/restaurant-workshop"; import * as restaurants from "./schema/restaurants"; import * as sessions from "./schema/sessions"; @@ -21,6 +22,7 @@ export const schema = { ...guests, ...dishes, ...dishCategories, + ...manyToMany, }; export type Schema = typeof schema; diff --git a/src/guests/entities/guest.entity.ts b/src/guests/entities/guest.entity.ts new file mode 100644 index 0000000..7ba1ba9 --- /dev/null +++ b/src/guests/entities/guest.entity.ts @@ -0,0 +1,75 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IGuest } from "@postgress-db/schema/guests"; +import { Expose } from "class-transformer"; +import { IsISO8601, IsNumber, IsString, IsUUID } from "class-validator"; + +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() + @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..8ae09bb --- /dev/null +++ b/src/guests/guests.controller.ts @@ -0,0 +1,59 @@ +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 { Get } from "@nestjs/common"; +import { + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; +import { GuestsPaginatedDto } from "src/guests/entities/guests-paginated.entity"; +import { GuestsService } from "src/guests/guests.service"; + +@RequireSessionAuth() +@Controller("guests") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class GuestsController { + constructor(private readonly guestsService: GuestsService) {} + + @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", "updatedAt", "createdAt"], + }) + 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, + }, + }; + } +} 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..022a986 --- /dev/null +++ b/src/guests/guests.service.ts @@ -0,0 +1,60 @@ +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 { Inject, Injectable } from "@nestjs/common"; +import { DrizzleUtils } from "@postgress-db/drizzle-utils"; +import { schema } from "@postgress-db/drizzle.module"; +import { asc, count, desc, sql } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +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); + } +} diff --git a/src/main.ts b/src/main.ts index 8d0aa33..27c2d5e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,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); diff --git a/src/timezones/timezones.controller.ts b/src/timezones/timezones.controller.ts index b761741..7d0a480 100644 --- a/src/timezones/timezones.controller.ts +++ b/src/timezones/timezones.controller.ts @@ -12,9 +12,7 @@ import { TimezonesListEntity } from "src/timezones/entities/timezones-list.entit import { TimezonesService } from "src/timezones/timezones.service"; @RequireSessionAuth() -@Controller("timezones", { - tags: ["Timezones"], -}) +@Controller("timezones") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) export class TimezonesController { diff --git a/src/workers/entities/worker.entity.ts b/src/workers/entities/worker.entity.ts index 08e2842..5d6f0b8 100644 --- a/src/workers/entities/worker.entity.ts +++ b/src/workers/entities/worker.entity.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { IWorker, ZodWorkerRole } from "@postgress-db/schema/workers"; import { Exclude, Expose } from "class-transformer"; import { @@ -35,6 +35,7 @@ export class WorkerEntity implements IWorker { description: "Unique identifier of the restaurant", example: null, }) + @ApiPropertyOptional() restaurantId: string | null; @IsOptional() @@ -45,6 +46,7 @@ export class WorkerEntity implements IWorker { example: "Restaurant Name", type: String, }) + @ApiPropertyOptional() restaurantName: string | null; @IsString() @@ -85,6 +87,7 @@ export class WorkerEntity implements IWorker { example: new Date("2021-08-01T00:00:00.000Z"), type: Date, }) + @ApiPropertyOptional() hiredAt: Date | null; @IsOptional() @@ -95,6 +98,7 @@ export class WorkerEntity implements IWorker { example: null, type: Date, }) + @ApiPropertyOptional() firedAt: Date | null; @IsOptional() @@ -105,6 +109,7 @@ export class WorkerEntity implements IWorker { example: new Date(), type: Date, }) + @ApiPropertyOptional() onlineAt: Date | null; @IsISO8601() From 1a4a644b5f0984a38f9addc63cf6ce2afb5f1f2e Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 9 Jan 2025 17:13:08 +0200 Subject: [PATCH 019/180] feat: finish crud for guests --- src/@core/decorators/is-phone.decorator.ts | 25 ++++++ src/guests/dtos/create-guest.dto.ts | 7 ++ src/guests/dtos/update-guest.dto.ts | 6 ++ src/guests/entities/guest.entity.ts | 2 + src/guests/guests.controller.ts | 91 +++++++++++++++++++++- src/guests/guests.service.ts | 82 ++++++++++++++++++- 6 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 src/@core/decorators/is-phone.decorator.ts create mode 100644 src/guests/dtos/create-guest.dto.ts create mode 100644 src/guests/dtos/update-guest.dto.ts diff --git a/src/@core/decorators/is-phone.decorator.ts b/src/@core/decorators/is-phone.decorator.ts new file mode 100644 index 0000000..f00e24a --- /dev/null +++ b/src/@core/decorators/is-phone.decorator.ts @@ -0,0 +1,25 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from "class-validator"; +import { isValidPhoneNumber } from "libphonenumber-js"; + +export function IsPhoneNumber(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: "isPhoneNumber", + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + return typeof value === "string" && isValidPhoneNumber(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid phone number`; + }, + }, + }); + }; +} 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 index 7ba1ba9..ffccf86 100644 --- a/src/guests/entities/guest.entity.ts +++ b/src/guests/entities/guest.entity.ts @@ -1,3 +1,4 @@ +import { IsPhoneNumber } from "@core/decorators/is-phone.decorator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { IGuest } from "@postgress-db/schema/guests"; import { Expose } from "class-transformer"; @@ -22,6 +23,7 @@ export class GuestEntity implements IGuest { @Expose() @IsString() + @IsPhoneNumber() @ApiProperty({ description: "Phone number of the guest", example: "+1234567890", diff --git a/src/guests/guests.controller.ts b/src/guests/guests.controller.ts index 8ae09bb..3c8a1bb 100644 --- a/src/guests/guests.controller.ts +++ b/src/guests/guests.controller.ts @@ -6,16 +6,25 @@ import { } from "@core/decorators/pagination.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; import { ISorting, SortingParams } from "@core/decorators/sorting.decorator"; -import { Get } from "@nestjs/common"; +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 { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; -import { GuestsPaginatedDto } from "src/guests/entities/guests-paginated.entity"; -import { GuestsService } from "src/guests/guests.service"; + +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"; @RequireSessionAuth() @Controller("guests") @@ -35,7 +44,7 @@ export class GuestsController { }) async findMany( @SortingParams({ - fields: ["id", "name", "updatedAt", "createdAt"], + fields: ["id", "name", "updatedAt", "createdAt", "lastVisitAt"], }) sorting: ISorting, @PaginationParams() pagination: IPagination, @@ -56,4 +65,78 @@ export class GuestsController { }, }; } + + @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("Failed to create guest"); + } + + return guest; + } + + @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("Id must be a string and provided"); + } + + const guest = await this.guestsService.findById(id); + + if (!guest) { + throw new NotFoundException("Guest with this id doesn't exist"); + } + + return guest; + } + + @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("Id must be a string and provided"); + } + + const updatedGuest = await this.guestsService.update(id, { + ...data, + updatedAt: new Date(), + }); + + if (!updatedGuest) { + throw new NotFoundException("Guest with this id doesn't exist"); + } + + return updatedGuest; + } } diff --git a/src/guests/guests.service.ts b/src/guests/guests.service.ts index 022a986..2c9ae40 100644 --- a/src/guests/guests.service.ts +++ b/src/guests/guests.service.ts @@ -4,12 +4,17 @@ import { 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, sql } from "drizzle-orm"; +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() @@ -57,4 +62,79 @@ export class GuestsService { .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("Invalid phone number"); + } + // Format to E.164 format (e.g., +12133734253) + return phoneNumber.format("E.164"); + } catch (error) { + throw new BadRequestException("Invalid phone number format"); + } + } + + 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("Failed to create guest"); + } + + return guest; + } + + public async update( + id: string, + dto: UpdateGuestDto, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException( + "You should provide at least one field to update", + ); + } + + // 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]; + } } From 0ec6c4c2d69a120d4db3f6b72f396560159f5aa8 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 10 Jan 2025 13:37:01 +0200 Subject: [PATCH 020/180] feat: dishes crud --- src/app.module.ts | 2 + src/dishes/dishes.controller.ts | 149 ++++++++++++++++++ src/dishes/dishes.module.ts | 13 ++ src/dishes/dishes.service.ts | 115 ++++++++++++++ src/dishes/dtos/create-dish.dto.ts | 17 ++ src/dishes/dtos/update-dish.dto.ts | 7 + src/dishes/entities/dish.entity.ts | 121 ++++++++++++++ .../entities/dishes-paginated.entity.ts | 15 ++ src/drizzle/schema/dishes.ts | 5 + src/guests/guests.controller.ts | 11 +- .../restaurant-workshop-worker.entity.ts | 8 +- 11 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 src/dishes/dishes.controller.ts create mode 100644 src/dishes/dishes.module.ts create mode 100644 src/dishes/dishes.service.ts create mode 100644 src/dishes/dtos/create-dish.dto.ts create mode 100644 src/dishes/dtos/update-dish.dto.ts create mode 100644 src/dishes/entities/dish.entity.ts create mode 100644 src/dishes/entities/dishes-paginated.entity.ts diff --git a/src/app.module.ts b/src/app.module.ts index 3171a29..2be6876 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { MongooseModule } from "@nestjs/mongoose"; import { ThrottlerModule } from "@nestjs/throttler"; import { ZodValidationPipe } from "nestjs-zod"; import { AddressesModule } from "src/addresses/addresses.module"; +import { DishesModule } from "src/dishes/dishes.module"; import { GuestsModule } from "src/guests/guests.module"; import { TimezonesModule } from "src/timezones/timezones.module"; @@ -48,6 +49,7 @@ import { WorkersModule } from "./workers/workers.module"; RestaurantsModule, AddressesModule, GuestsModule, + DishesModule, ], providers: [ { diff --git a/src/dishes/dishes.controller.ts b/src/dishes/dishes.controller.ts new file mode 100644 index 0000000..245c990 --- /dev/null +++ b/src/dishes/dishes.controller.ts @@ -0,0 +1,149 @@ +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 { RequireSessionAuth } from "src/auth/decorators/session-auth.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"; + +@RequireSessionAuth() +@Controller("dishes") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class DishesController { + constructor(private readonly dishesService: DishesService) {} + + @Get() + @ApiOperation({ + summary: "Gets dishes that are available in system", + }) + @Serializable(DishesPaginatedDto) + @ApiOkResponse({ + description: "Dishes have been successfully fetched", + type: DishesPaginatedDto, + }) + async findMany( + @SortingParams({ + fields: [ + "id", + "name", + "cookingTimeInMin", + "weight", + "updatedAt", + "createdAt", + ], + }) + sorting: ISorting, + @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, + ): Promise { + const total = await this.dishesService.getTotalCount(filters); + const data = await this.dishesService.findMany({ + pagination, + sorting, + filters, + }); + + return { + data, + meta: { + ...pagination, + total, + }, + }; + } + + @Post() + @Serializable(DishEntity) + @ApiOperation({ summary: "Creates a new dish" }) + @ApiCreatedResponse({ description: "Dish has been successfully created" }) + async create(@Body() data: CreateDishDto): Promise { + const dish = await this.dishesService.create(data); + + if (!dish) { + throw new BadRequestException("Failed to create dish"); + } + + return dish; + } + + @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("Id must be a string and provided"); + } + + const dish = await this.dishesService.findById(id); + + if (!dish) { + throw new NotFoundException("Dish with this id doesn't exist"); + } + + return dish; + } + + @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", + }) + async update( + @Param("id") id: string, + @Body() data: UpdateDishDto, + ): Promise { + if (!id) { + throw new BadRequestException("Id must be a string and provided"); + } + + const updatedDish = await this.dishesService.update(id, { + ...data, + updatedAt: new Date(), + }); + + if (!updatedDish) { + throw new NotFoundException("Dish with this id doesn't exist"); + } + + return updatedDish; + } +} diff --git a/src/dishes/dishes.module.ts b/src/dishes/dishes.module.ts new file mode 100644 index 0000000..c227d80 --- /dev/null +++ b/src/dishes/dishes.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; + +import { DishesController } from "./dishes.controller"; +import { DishesService } from "./dishes.service"; + +@Module({ + imports: [DrizzleModule], + controllers: [DishesController], + providers: [DishesService], + exports: [DishesService], +}) +export class DishesModule {} diff --git a/src/dishes/dishes.service.ts b/src/dishes/dishes.service.ts new file mode 100644 index 0000000..00b1eaf --- /dev/null +++ b/src/dishes/dishes.service.ts @@ -0,0 +1,115 @@ +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 { 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?: IFilters): Promise { + const query = this.pg + .select({ + value: count(), + }) + .from(schema.dishes); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(schema.dishes, 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.dishes); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(schema.dishes, 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); + } + + public async create(dto: CreateDishDto): Promise { + const dishes = await this.pg + .insert(schema.dishes) + .values({ + ...dto, + }) + .returning(); + + const dish = dishes[0]; + if (!dish) { + throw new ServerErrorException("Failed to create dish"); + } + + return dish; + } + + public async update( + id: string, + dto: UpdateDishDto, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException( + "You should provide at least one field to update", + ); + } + + await this.pg + .update(schema.dishes) + .set(dto) + .where(eq(schema.dishes.id, id)); + + const result = await this.pg + .select() + .from(schema.dishes) + .where(eq(schema.dishes.id, id)) + .limit(1); + + return result[0]; + } + + public async findById(id: string): Promise { + const result = await this.pg + .select() + .from(schema.dishes) + .where(eq(schema.dishes.id, id)) + .limit(1); + + return result[0]; + } +} diff --git a/src/dishes/dtos/create-dish.dto.ts b/src/dishes/dtos/create-dish.dto.ts new file mode 100644 index 0000000..ac3b3b8 --- /dev/null +++ b/src/dishes/dtos/create-dish.dto.ts @@ -0,0 +1,17 @@ +import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; + +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", + ]), + ), +) {} diff --git a/src/dishes/dtos/update-dish.dto.ts b/src/dishes/dtos/update-dish.dto.ts new file mode 100644 index 0000000..84f635b --- /dev/null +++ b/src/dishes/dtos/update-dish.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateDishDto } from "./create-dish.dto"; + +export class UpdateDishDto extends PartialType(CreateDishDto) { + updatedAt?: Date; +} diff --git a/src/dishes/entities/dish.entity.ts b/src/dishes/entities/dish.entity.ts new file mode 100644 index 0000000..d9f98dc --- /dev/null +++ b/src/dishes/entities/dish.entity.ts @@ -0,0 +1,121 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IDish, ZodWeightMeasureEnum } from "@postgress-db/schema/dishes"; +import { Expose } from "class-transformer"; +import { + IsBoolean, + IsEnum, + IsISO8601, + IsNumber, + IsString, + IsUUID, +} from "class-validator"; + +export class DishEntity implements IDish { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the dish", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @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() + @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/drizzle/schema/dishes.ts b/src/drizzle/schema/dishes.ts index a58e107..68a772f 100644 --- a/src/drizzle/schema/dishes.ts +++ b/src/drizzle/schema/dishes.ts @@ -9,12 +9,17 @@ import { timestamp, uuid, } from "drizzle-orm/pg-core"; +import { z } from "zod"; export const weightMeasureEnum = pgEnum("weightMeasureEnum", [ "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(), diff --git a/src/guests/guests.controller.ts b/src/guests/guests.controller.ts index 3c8a1bb..a750852 100644 --- a/src/guests/guests.controller.ts +++ b/src/guests/guests.controller.ts @@ -44,7 +44,16 @@ export class GuestsController { }) async findMany( @SortingParams({ - fields: ["id", "name", "updatedAt", "createdAt", "lastVisitAt"], + fields: [ + "id", + "name", + "phone", + "email", + "bonusBalance", + "updatedAt", + "createdAt", + "lastVisitAt", + ], }) sorting: ISorting, @PaginationParams() pagination: IPagination, diff --git a/src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts b/src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts index 042d92f..bc00993 100644 --- a/src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts +++ b/src/restaurants/workshops/entity/restaurant-workshop-worker.entity.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { workerRoleEnum } from "@postgress-db/schema/workers"; +import { ZodWorkerRole } from "@postgress-db/schema/workers"; import { Expose } from "class-transformer"; export class WorkshopWorkerEntity { @@ -27,7 +27,9 @@ export class WorkshopWorkerEntity { @Expose() @ApiProperty({ description: "Worker role", - enum: workerRoleEnum.enumValues, + enum: ZodWorkerRole.Enum, + example: ZodWorkerRole.Enum.ADMIN, + examples: Object.values(ZodWorkerRole.Enum), }) - role: (typeof workerRoleEnum.enumValues)[number]; + role: typeof ZodWorkerRole._type; } From ec82dbd2aa5d058d8db784c3e21933bf640e5879 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 10 Jan 2025 13:41:09 +0200 Subject: [PATCH 021/180] feat: dish categories crud --- src/app.module.ts | 2 + .../dish-categories.controller.ts | 154 ++++++++++++++++++ src/dish-categories/dish-categories.module.ts | 13 ++ .../dish-categories.service.ts | 121 ++++++++++++++ .../dtos/create-dish-category.dto.ts | 14 ++ .../dtos/update-dish-category.dto.ts | 7 + .../dish-categories-paginated.entity.ts | 15 ++ .../entities/dish-category.entity.ts | 71 ++++++++ 8 files changed, 397 insertions(+) create mode 100644 src/dish-categories/dish-categories.controller.ts create mode 100644 src/dish-categories/dish-categories.module.ts create mode 100644 src/dish-categories/dish-categories.service.ts create mode 100644 src/dish-categories/dtos/create-dish-category.dto.ts create mode 100644 src/dish-categories/dtos/update-dish-category.dto.ts create mode 100644 src/dish-categories/entities/dish-categories-paginated.entity.ts create mode 100644 src/dish-categories/entities/dish-category.entity.ts diff --git a/src/app.module.ts b/src/app.module.ts index 2be6876..216cf48 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { MongooseModule } from "@nestjs/mongoose"; import { ThrottlerModule } from "@nestjs/throttler"; import { ZodValidationPipe } from "nestjs-zod"; import { AddressesModule } from "src/addresses/addresses.module"; +import { DishCategoriesModule } from "src/dish-categories/dish-categories.module"; import { DishesModule } from "src/dishes/dishes.module"; import { GuestsModule } from "src/guests/guests.module"; import { TimezonesModule } from "src/timezones/timezones.module"; @@ -50,6 +51,7 @@ import { WorkersModule } from "./workers/workers.module"; AddressesModule, GuestsModule, DishesModule, + DishCategoriesModule, ], providers: [ { diff --git a/src/dish-categories/dish-categories.controller.ts b/src/dish-categories/dish-categories.controller.ts new file mode 100644 index 0000000..9a80991 --- /dev/null +++ b/src/dish-categories/dish-categories.controller.ts @@ -0,0 +1,154 @@ +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 { RequireSessionAuth } from "src/auth/decorators/session-auth.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"; + +@RequireSessionAuth() +@Controller("dish-categories") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class DishCategoriesController { + constructor(private readonly dishCategoriesService: DishCategoriesService) {} + + @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, + }, + }; + } + + @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; + } + + @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; + } + + @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..7059e0b --- /dev/null +++ b/src/dish-categories/dish-categories.service.ts @@ -0,0 +1,121 @@ +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 { 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 query = this.pg.select().from(schema.dishCategories); + + if (filters) { + query.where( + DrizzleUtils.buildFilterConditions(schema.dishCategories, 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); + } + + public async create( + dto: CreateDishCategoryDto, + ): Promise { + const categories = await this.pg + .insert(schema.dishCategories) + .values({ + ...dto, + }) + .returning(); + + const category = categories[0]; + if (!category) { + throw new ServerErrorException("Failed to create dish category"); + } + + return category; + } + + public async update( + id: string, + dto: UpdateDishCategoryDto, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException( + "You should provide at least one field to update", + ); + } + + 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..8fb8d2f --- /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"]), + 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..07456d4 --- /dev/null +++ b/src/dish-categories/entities/dish-category.entity.ts @@ -0,0 +1,71 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IDishCategory } from "@postgress-db/schema/dish-categories"; +import { Expose } from "class-transformer"; +import { + IsBoolean, + IsISO8601, + IsNumber, + IsString, + IsUUID, +} from "class-validator"; + +export class DishCategoryEntity implements IDishCategory { + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the dish category", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: 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; +} From 7f215099776e036c2e0899c15bffd527006954fd Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 10 Jan 2025 13:45:14 +0200 Subject: [PATCH 022/180] refactor: move drizzle folder to base --- src/{ => @base}/drizzle/clear.ts | 0 src/{ => @base}/drizzle/drizzle-utils.ts | 0 src/{ => @base}/drizzle/drizzle.module.ts | 2 +- src/{ => @base}/drizzle/migrate.ts | 0 src/{ => @base}/drizzle/migrations/0000_amazing_enchantress.sql | 0 src/{ => @base}/drizzle/migrations/meta/0000_snapshot.json | 0 src/{ => @base}/drizzle/migrations/meta/_journal.json | 0 src/{ => @base}/drizzle/schema/dish-categories.ts | 0 src/{ => @base}/drizzle/schema/dishes.ts | 0 src/{ => @base}/drizzle/schema/general.ts | 0 src/{ => @base}/drizzle/schema/guests.ts | 0 src/{ => @base}/drizzle/schema/many-to-many.ts | 0 src/{ => @base}/drizzle/schema/restaurant-workshop.ts | 0 src/{ => @base}/drizzle/schema/restaurants.ts | 0 src/{ => @base}/drizzle/schema/sessions.ts | 0 src/{ => @base}/drizzle/schema/workers.ts | 0 src/{ => @base}/drizzle/seed.ts | 0 src/app.module.ts | 2 +- src/workers/workers.service.ts | 2 +- tsconfig.json | 2 +- 20 files changed, 4 insertions(+), 4 deletions(-) rename src/{ => @base}/drizzle/clear.ts (100%) rename src/{ => @base}/drizzle/drizzle-utils.ts (100%) rename src/{ => @base}/drizzle/drizzle.module.ts (96%) rename src/{ => @base}/drizzle/migrate.ts (100%) rename src/{ => @base}/drizzle/migrations/0000_amazing_enchantress.sql (100%) rename src/{ => @base}/drizzle/migrations/meta/0000_snapshot.json (100%) rename src/{ => @base}/drizzle/migrations/meta/_journal.json (100%) rename src/{ => @base}/drizzle/schema/dish-categories.ts (100%) rename src/{ => @base}/drizzle/schema/dishes.ts (100%) rename src/{ => @base}/drizzle/schema/general.ts (100%) rename src/{ => @base}/drizzle/schema/guests.ts (100%) rename src/{ => @base}/drizzle/schema/many-to-many.ts (100%) rename src/{ => @base}/drizzle/schema/restaurant-workshop.ts (100%) rename src/{ => @base}/drizzle/schema/restaurants.ts (100%) rename src/{ => @base}/drizzle/schema/sessions.ts (100%) rename src/{ => @base}/drizzle/schema/workers.ts (100%) rename src/{ => @base}/drizzle/seed.ts (100%) diff --git a/src/drizzle/clear.ts b/src/@base/drizzle/clear.ts similarity index 100% rename from src/drizzle/clear.ts rename to src/@base/drizzle/clear.ts diff --git a/src/drizzle/drizzle-utils.ts b/src/@base/drizzle/drizzle-utils.ts similarity index 100% rename from src/drizzle/drizzle-utils.ts rename to src/@base/drizzle/drizzle-utils.ts diff --git a/src/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts similarity index 96% rename from src/drizzle/drizzle.module.ts rename to src/@base/drizzle/drizzle.module.ts index 6a709b6..10e4a78 100644 --- a/src/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -3,7 +3,7 @@ import { ConfigService } from "@nestjs/config"; import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; -import { PG_CONNECTION } from "../constants"; +import { PG_CONNECTION } from "../../constants"; import * as dishCategories from "./schema/dish-categories"; import * as dishes from "./schema/dishes"; diff --git a/src/drizzle/migrate.ts b/src/@base/drizzle/migrate.ts similarity index 100% rename from src/drizzle/migrate.ts rename to src/@base/drizzle/migrate.ts diff --git a/src/drizzle/migrations/0000_amazing_enchantress.sql b/src/@base/drizzle/migrations/0000_amazing_enchantress.sql similarity index 100% rename from src/drizzle/migrations/0000_amazing_enchantress.sql rename to src/@base/drizzle/migrations/0000_amazing_enchantress.sql diff --git a/src/drizzle/migrations/meta/0000_snapshot.json b/src/@base/drizzle/migrations/meta/0000_snapshot.json similarity index 100% rename from src/drizzle/migrations/meta/0000_snapshot.json rename to src/@base/drizzle/migrations/meta/0000_snapshot.json diff --git a/src/drizzle/migrations/meta/_journal.json b/src/@base/drizzle/migrations/meta/_journal.json similarity index 100% rename from src/drizzle/migrations/meta/_journal.json rename to src/@base/drizzle/migrations/meta/_journal.json diff --git a/src/drizzle/schema/dish-categories.ts b/src/@base/drizzle/schema/dish-categories.ts similarity index 100% rename from src/drizzle/schema/dish-categories.ts rename to src/@base/drizzle/schema/dish-categories.ts diff --git a/src/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts similarity index 100% rename from src/drizzle/schema/dishes.ts rename to src/@base/drizzle/schema/dishes.ts diff --git a/src/drizzle/schema/general.ts b/src/@base/drizzle/schema/general.ts similarity index 100% rename from src/drizzle/schema/general.ts rename to src/@base/drizzle/schema/general.ts diff --git a/src/drizzle/schema/guests.ts b/src/@base/drizzle/schema/guests.ts similarity index 100% rename from src/drizzle/schema/guests.ts rename to src/@base/drizzle/schema/guests.ts diff --git a/src/drizzle/schema/many-to-many.ts b/src/@base/drizzle/schema/many-to-many.ts similarity index 100% rename from src/drizzle/schema/many-to-many.ts rename to src/@base/drizzle/schema/many-to-many.ts diff --git a/src/drizzle/schema/restaurant-workshop.ts b/src/@base/drizzle/schema/restaurant-workshop.ts similarity index 100% rename from src/drizzle/schema/restaurant-workshop.ts rename to src/@base/drizzle/schema/restaurant-workshop.ts diff --git a/src/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts similarity index 100% rename from src/drizzle/schema/restaurants.ts rename to src/@base/drizzle/schema/restaurants.ts diff --git a/src/drizzle/schema/sessions.ts b/src/@base/drizzle/schema/sessions.ts similarity index 100% rename from src/drizzle/schema/sessions.ts rename to src/@base/drizzle/schema/sessions.ts diff --git a/src/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts similarity index 100% rename from src/drizzle/schema/workers.ts rename to src/@base/drizzle/schema/workers.ts diff --git a/src/drizzle/seed.ts b/src/@base/drizzle/seed.ts similarity index 100% rename from src/drizzle/seed.ts rename to src/@base/drizzle/seed.ts diff --git a/src/app.module.ts b/src/app.module.ts index 216cf48..82ec95f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,9 +13,9 @@ import { DishesModule } from "src/dishes/dishes.module"; import { GuestsModule } from "src/guests/guests.module"; import { TimezonesModule } from "src/timezones/timezones.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"; diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index 5403a58..a804fa5 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -10,8 +10,8 @@ import { IWorker } from "@postgress-db/schema/workers"; import * as argon2 from "argon2"; import { asc, count, desc, eq, 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 { DrizzleUtils } from "src/drizzle/drizzle-utils"; import { CreateWorkerDto, UpdateWorkerDto } from "./dto/req/put-worker.dto"; import { WorkerEntity } from "./entities/worker.entity"; diff --git a/tsconfig.json b/tsconfig.json index 696f536..c6861e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,7 @@ "noFallthroughCasesInSwitch": true, "paths": { "@core/*": ["src/@core/*"], - "@postgress-db/*": ["src/drizzle/*"], + "@postgress-db/*": ["src/@base/drizzle/*"], }, }, "include": ["src/**/*"], From d27fec2d9cee2dd0373132ef7780f653f5228da4 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 10 Jan 2025 15:12:59 +0200 Subject: [PATCH 023/180] fix: errors --- drizzle.config.ts | 4 ++-- package.json | 8 ++++---- src/@base/drizzle/schema/dish-categories.ts | 6 +----- src/dish-categories/dish-categories.service.ts | 7 +++++++ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/drizzle.config.ts b/drizzle.config.ts index 3f6c7bf..27000ab 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,8 +6,8 @@ dotenv.config({ }); export default { - schema: "./src/drizzle/schema", - out: "./src/drizzle/migrations", + schema: "./src/@base/drizzle/schema", + out: "./src/@base/drizzle/migrations", // driver: "pg", dialect: "postgresql", dbCredentials: { diff --git a/package.json b/package.json index 4401f00..d2b502d 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ "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/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": "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 src/drizzle/seed.ts" + "db:seed": "node -r esbuild-register src/@base/drizzle/seed.ts" }, "dependencies": { "@liaoliaots/nestjs-redis": "^10.0.0", diff --git a/src/@base/drizzle/schema/dish-categories.ts b/src/@base/drizzle/schema/dish-categories.ts index e4bab41..15c9d4b 100644 --- a/src/@base/drizzle/schema/dish-categories.ts +++ b/src/@base/drizzle/schema/dish-categories.ts @@ -22,11 +22,7 @@ export const dishCategories = pgTable("dishCategories", { showForGuests: boolean("showForGuests").notNull().default(false), // Sorting index in the admin menu // - sortIndex: integer("sortIndex") - .notNull() - .default( - sql`nextval(pg_get_serial_sequence('dishCategories', 'sortIndex'))`, - ), + sortIndex: integer("sortIndex").notNull(), // Default timestamps // createdAt: timestamp("createdAt").notNull().defaultNow(), diff --git a/src/dish-categories/dish-categories.service.ts b/src/dish-categories/dish-categories.service.ts index 7059e0b..4e88b38 100644 --- a/src/dish-categories/dish-categories.service.ts +++ b/src/dish-categories/dish-categories.service.ts @@ -70,10 +70,17 @@ export class DishCategoriesService { public async create( dto: CreateDishCategoryDto, ): Promise { + 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, }) .returning(); From 289a1d0f6de7c2db6addfc381f61bd2cd961712e Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 10 Jan 2025 15:17:05 +0200 Subject: [PATCH 024/180] feat: drag and drop for sort indexes of categories --- src/@base/drizzle/schema/dish-categories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/@base/drizzle/schema/dish-categories.ts b/src/@base/drizzle/schema/dish-categories.ts index 15c9d4b..13a00d6 100644 --- a/src/@base/drizzle/schema/dish-categories.ts +++ b/src/@base/drizzle/schema/dish-categories.ts @@ -1,5 +1,5 @@ import { dishesToCategories } from "@postgress-db/schema/many-to-many"; -import { relations, sql } from "drizzle-orm"; +import { relations } from "drizzle-orm"; import { boolean, integer, From b712aeee0e9ea4ef6720949af474a7bf500dd628 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 13 Jan 2025 16:30:49 +0200 Subject: [PATCH 025/180] feat: s3 service with controller --- package.json | 2 + src/@base/drizzle/drizzle.module.ts | 2 + src/@base/drizzle/schema/files.ts | 37 + src/@base/s3/s3.module.ts | 9 + src/@base/s3/s3.service.ts | 109 +++ src/@core/config/app.ts | 6 + src/app.module.ts | 6 + src/files/dto/upload-form-data.dto.ts | 21 + src/files/entitites/file.entity.ts | 103 +++ src/files/files.controller.ts | 49 + src/files/files.module.ts | 14 + src/files/files.service.ts | 56 ++ yarn.lock | 1185 ++++++++++++++++++++++++- 13 files changed, 1593 insertions(+), 6 deletions(-) create mode 100644 src/@base/drizzle/schema/files.ts create mode 100644 src/@base/s3/s3.module.ts create mode 100644 src/@base/s3/s3.service.ts create mode 100644 src/files/dto/upload-form-data.dto.ts create mode 100644 src/files/entitites/file.entity.ts create mode 100644 src/files/files.controller.ts create mode 100644 src/files/files.module.ts create mode 100644 src/files/files.service.ts diff --git a/package.json b/package.json index d2b502d..6fe5c2b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "db:seed": "node -r esbuild-register src/@base/drizzle/seed.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", @@ -56,6 +57,7 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.2.0", "multer": "^1.4.5-lts.1", + "nestjs-form-data": "^1.9.92", "nestjs-zod": "^4.2.0", "passport-jwt": "^4.0.1", "pg": "^8.11.3", diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index 10e4a78..2f25c0c 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -7,6 +7,7 @@ import { PG_CONNECTION } from "../../constants"; import * as dishCategories from "./schema/dish-categories"; import * as dishes from "./schema/dishes"; +import * as files from "./schema/files"; import * as guests from "./schema/guests"; import * as manyToMany from "./schema/many-to-many"; import * as restaurantWorkshops from "./schema/restaurant-workshop"; @@ -23,6 +24,7 @@ export const schema = { ...dishes, ...dishCategories, ...manyToMany, + ...files, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/files.ts b/src/@base/drizzle/schema/files.ts new file mode 100644 index 0000000..cfd3b93 --- /dev/null +++ b/src/@base/drizzle/schema/files.ts @@ -0,0 +1,37 @@ +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("groupId"), + + // Original name of the file // + originalName: text("originalName").notNull(), + + // Mime type of the file // + mimeType: text("mimeType").notNull(), + + // Extension of the file // + extension: text("extension").notNull(), + + // Bucket name // + bucketName: text("bucketName").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("uploadedByUserId"), + + // Created at // + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + +export type IFile = typeof files.$inferSelect; 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..89efc1b --- /dev/null +++ b/src/@base/s3/s3.service.ts @@ -0,0 +1,109 @@ +import * as path from "path"; + +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +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: process.env.S3_REGION as string, + accessKeyId: process.env.S3_ACCESS_KEY_ID as string, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY as string, + endpoint: process.env.S3_ENDPOINT as string, + bucketName: process.env.S3_BUCKET_NAME as string, + }; + } + + 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/@core/config/app.ts b/src/@core/config/app.ts index 2bab655..82b1414 100644 --- a/src/@core/config/app.ts +++ b/src/@core/config/app.ts @@ -5,6 +5,12 @@ export const configApp = (app: INestApplication) => { // Parse cookies app.use(cookieParser()); + // app.useGlobalPipes( + // new ValidationPipe({ + // transform: true, // Transform is recomended configuration for avoind issues with arrays of files transformations + // }), + // ); + // Enable CORS app.enableCors({ origin: [ diff --git a/src/app.module.ts b/src/app.module.ts index 82ec95f..0fd1a3e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,10 +6,13 @@ import { ConfigModule, ConfigService } from "@nestjs/config"; import { APP_FILTER, APP_GUARD, APP_PIPE } from "@nestjs/core"; import { MongooseModule } from "@nestjs/mongoose"; import { ThrottlerModule } from "@nestjs/throttler"; +import { NestjsFormDataModule } from "nestjs-form-data"; import { ZodValidationPipe } from "nestjs-zod"; +import { S3Module } from "src/@base/s3/s3.module"; import { AddressesModule } from "src/addresses/addresses.module"; import { DishCategoriesModule } from "src/dish-categories/dish-categories.module"; import { DishesModule } from "src/dishes/dishes.module"; +import { FilesModule } from "src/files/files.module"; import { GuestsModule } from "src/guests/guests.module"; import { TimezonesModule } from "src/timezones/timezones.module"; @@ -52,6 +55,9 @@ import { WorkersModule } from "./workers/workers.module"; GuestsModule, DishesModule, DishCategoriesModule, + S3Module, + FilesModule, + NestjsFormDataModule, ], providers: [ { 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..1f0bb26 --- /dev/null +++ b/src/files/entitites/file.entity.ts @@ -0,0 +1,103 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IFile } from "@postgress-db/schema/files"; +import { Expose } from "class-transformer"; +import { + IsISO8601, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from "class-validator"; + +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..bbc707a --- /dev/null +++ b/src/files/files.controller.ts @@ -0,0 +1,49 @@ +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 { RequireSessionAuth } from "src/auth/decorators/session-auth.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"; + +@RequireSessionAuth() +@Controller("files") +@ApiForbiddenResponse({ description: "Forbidden" }) +@ApiUnauthorizedResponse({ description: "Unauthorized" }) +export class FilesController { + constructor(private readonly filesService: FilesService) {} + + @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, + }); + } + + @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/yarn.lock b/yarn.lock index 69489be..9cae364 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" @@ -1173,6 +1775,501 @@ 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" + +"@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" @@ -2132,6 +3229,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" @@ -2206,7 +3308,7 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" -busboy@^1.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== @@ -2481,6 +3583,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" @@ -3310,6 +4422,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" @@ -3346,6 +4465,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" @@ -3802,7 +4930,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== @@ -4969,7 +6097,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== @@ -5082,6 +6210,18 @@ 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-form-data@^1.9.92: + version "1.9.92" + resolved "https://registry.yarnpkg.com/nestjs-form-data/-/nestjs-form-data-1.9.92.tgz#b4ddd03eea33585e41c87541532143cc424681a6" + integrity sha512-HsK2Zsh0ZZF+R7bSJ3LqUax0QQXm7AcSMTO0zknF6B+jewaejU+I74AUoerIg6Iibvg2ekDeOyq91WvS2YghxQ== + dependencies: + uid "^2.0.0" + busboy "^1.6.0" + concat-stream "^2.0.0" + file-type "^16.5.4" + mkdirp "^1.0.4" + type-is "^1.6.18" + nestjs-zod@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/nestjs-zod/-/nestjs-zod-4.2.0.tgz#0c2f00e791f827493b8226336bf157ed47ef8929" @@ -5381,6 +6521,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" @@ -5679,7 +6824,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== @@ -5688,6 +6833,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" @@ -6207,6 +7359,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" @@ -6361,6 +7526,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" @@ -6487,7 +7660,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== @@ -6549,7 +7722,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== From 144a6484b339af78f1ec443ba7eaa311c7769f0b Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 13 Jan 2025 17:36:12 +0200 Subject: [PATCH 026/180] feat: orders drizzle model init --- src/@base/drizzle/schema/orders.ts | 70 +++++++++++++++++++++++++ src/@base/drizzle/schema/restaurants.ts | 4 +- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/@base/drizzle/schema/orders.ts diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts new file mode 100644 index 0000000..9c286c6 --- /dev/null +++ b/src/@base/drizzle/schema/orders.ts @@ -0,0 +1,70 @@ +import { restaurants } from "@postgress-db/schema/restaurants"; +import { relations } from "drizzle-orm"; +import { + boolean, + integer, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const orderTypeEnum = pgEnum("orderTypeEnum", [ + "hall", + "banquet", + "takeaway", + "delivery", +]); + +export const ZodOrderTypeEnum = z.enum(orderTypeEnum.enumValues); + +export type OrderTypeEnum = typeof ZodOrderTypeEnum._type; + +export const orders = pgTable("orders", { + id: uuid("id").defaultRandom().primaryKey(), + + // Links // + restaurantId: uuid("restaurantId"), + + // Order number // + number: integer("number").notNull(), + + // Table number // + tableNumber: text("tableNumber"), + + // Order type // + type: orderTypeEnum("type").notNull(), + + // Note from the admins // + note: text("note").notNull().default(""), + + // Guest information // + guestId: uuid("guestId"), + phone: text("phone"), + guestName: text("guestName"), + + // Guests amount // + guestsAmount: integer("guestsAmount"), + + // Booleans flags // + isHidden: boolean("isHidden").notNull().default(false), + isAppOrder: boolean("isAppOrder").notNull().default(false), + + // Default timestamps + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), + deliveredAt: timestamp("deliveredAt"), + archivedAt: timestamp("archivedAt"), + removedAt: timestamp("removedAt"), +}); + +export type IOrder = typeof orders.$inferSelect; + +export const orderRelations = relations(orders, ({ one }) => ({ + restaurant: one(restaurants, { + fields: [orders.restaurantId], + references: [restaurants.id], + }), +})); diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 94dc9d6..1ddb90a 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -1,3 +1,4 @@ +import { orders } from "@postgress-db/schema/orders"; import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; import { relations } from "drizzle-orm"; import { @@ -70,11 +71,12 @@ export const restaurantRelations = relations(restaurants, ({ many }) => ({ export const restaurantHourRelations = relations( restaurantHours, - ({ one }) => ({ + ({ one, many }) => ({ restaurant: one(restaurants, { fields: [restaurantHours.restaurantId], references: [restaurants.id], }), + orders: many(orders), }), ); From f5d57aeda4b04f4f318bd9067a8dd2e545acc08c Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 13 Jan 2025 17:46:05 +0200 Subject: [PATCH 027/180] feat: dishes and image files relation --- src/@base/drizzle/schema/dishes.ts | 6 +++- src/@base/drizzle/schema/files.ts | 6 ++++ src/@base/drizzle/schema/many-to-many.ts | 35 ++++++++++++++++++++++-- src/@base/drizzle/schema/restaurants.ts | 4 +-- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts index 68a772f..c3514eb 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -1,4 +1,7 @@ -import { dishesToCategories } from "@postgress-db/schema/many-to-many"; +import { + dishesToCategories, + dishesToImages, +} from "@postgress-db/schema/many-to-many"; import { relations } from "drizzle-orm"; import { boolean, @@ -58,4 +61,5 @@ export type IDish = typeof dishes.$inferSelect; export const dishRelations = relations(dishes, ({ many }) => ({ dishesToCategories: many(dishesToCategories), + dishesToImages: many(dishesToImages), })); diff --git a/src/@base/drizzle/schema/files.ts b/src/@base/drizzle/schema/files.ts index cfd3b93..ee6f022 100644 --- a/src/@base/drizzle/schema/files.ts +++ b/src/@base/drizzle/schema/files.ts @@ -1,3 +1,5 @@ +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", { @@ -35,3 +37,7 @@ export const files = pgTable("files", { }); export type IFile = typeof files.$inferSelect; + +export const fileRelations = relations(files, ({ many }) => ({ + dishesToImages: many(dishesToImages), +})); diff --git a/src/@base/drizzle/schema/many-to-many.ts b/src/@base/drizzle/schema/many-to-many.ts index 77196cc..76e11f7 100644 --- a/src/@base/drizzle/schema/many-to-many.ts +++ b/src/@base/drizzle/schema/many-to-many.ts @@ -1,9 +1,12 @@ 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 { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"; +import { integer, pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"; -// Dishes to dish categories relation // +// ----------------------------------- // +// Dishes to dish categories relation // +// ----------------------------------- // export const dishesToCategories = pgTable( "dishesToCategories", { @@ -30,3 +33,31 @@ export const dishesToCategoriesRelations = relations( }), }), ); + +// ----------------------------------- // +// Dishes to images relation // +// ----------------------------------- // +export const dishesToImages = pgTable( + "dishesToImages", + { + dishId: uuid("dishId").notNull(), + imageFileId: uuid("imageFileId").notNull(), + sortIndex: integer("sortIndex").notNull().default(0), + }, + (t) => [ + primaryKey({ + columns: [t.dishId, t.imageFileId], + }), + ], +); + +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/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 1ddb90a..7b43cb1 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -67,16 +67,16 @@ export const restaurantRelations = relations(restaurants, ({ many }) => ({ restaurantHours: many(restaurantHours), workers: many(workers), workshops: many(restaurantWorkshops), + orders: many(orders), })); export const restaurantHourRelations = relations( restaurantHours, - ({ one, many }) => ({ + ({ one }) => ({ restaurant: one(restaurants, { fields: [restaurantHours.restaurantId], references: [restaurants.id], }), - orders: many(orders), }), ); From eab60ecc6dd2fbe864e27f73defb15a014fc3255 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 14 Jan 2025 15:03:06 +0200 Subject: [PATCH 028/180] refactor: auth module --- package.json | 2 + src/@base/drizzle/schema/many-to-many.ts | 4 + src/@base/drizzle/schema/sessions.ts | 8 +- src/@core/decorators/ip-address.decorator.ts | 12 + src/@core/decorators/user-agent.decorator.ts | 20 ++ src/@core/interfaces/request.ts | 26 +- src/addresses/addresses.controller.ts | 2 - src/app.module.ts | 2 - src/auth/auth.module.ts | 14 +- src/auth/auth.types.ts | 14 + src/auth/controllers/auth.controller.ts | 41 +-- src/auth/decorators/public.decorator.ts | 4 + src/auth/decorators/session-auth.decorator.ts | 5 - src/auth/decorators/set-cookie.decorator.ts | 11 - src/auth/guards/session-auth.guard.ts | 126 ++++---- .../interceptors/set-cookie.interceptor.ts | 51 ---- src/auth/services/auth.service.ts | 272 ++++++++++++++++-- .../dish-categories.controller.ts | 2 - src/dishes/dishes.controller.ts | 2 - src/files/files.controller.ts | 2 - src/guests/guests.controller.ts | 2 - .../@/controllers/restaurants.controller.ts | 2 - .../hours/restaurant-hours.controller.ts | 2 - .../restaurant-workshops.controller.ts | 2 - src/sessions/dto/session-payload.ts | 18 -- src/sessions/sessions.service.ts | 115 -------- src/timezones/timezones.controller.ts | 2 - src/workers/workers.controller.ts | 2 - yarn.lock | 10 + 29 files changed, 443 insertions(+), 332 deletions(-) create mode 100644 src/@core/decorators/ip-address.decorator.ts create mode 100644 src/@core/decorators/user-agent.decorator.ts create mode 100644 src/auth/decorators/public.decorator.ts delete mode 100644 src/auth/decorators/session-auth.decorator.ts delete mode 100644 src/auth/decorators/set-cookie.decorator.ts delete mode 100644 src/auth/interceptors/set-cookie.interceptor.ts delete mode 100644 src/sessions/dto/session-payload.ts delete mode 100644 src/sessions/sessions.service.ts diff --git a/package.json b/package.json index 6fe5c2b..0926818 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.2.0", "@nestjs/throttler": "^5.1.1", + "@supercharge/request-ip": "^1.2.0", "@vvo/tzdb": "^6.157.0", "argon2": "^0.31.2", "axios": "^1.7.9", @@ -50,6 +51,7 @@ "cookie-parser": "^1.4.6", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "dotenv": "^16.4.7", "drizzle-orm": "0.38.3", "drizzle-zod": "0.6.1", "eslint-plugin-import": "^2.29.1", diff --git a/src/@base/drizzle/schema/many-to-many.ts b/src/@base/drizzle/schema/many-to-many.ts index 76e11f7..5ba30b0 100644 --- a/src/@base/drizzle/schema/many-to-many.ts +++ b/src/@base/drizzle/schema/many-to-many.ts @@ -20,6 +20,8 @@ export const dishesToCategories = pgTable( ], ); +export type IDishesToCategories = typeof dishesToCategories.$inferSelect; + export const dishesToCategoriesRelations = relations( dishesToCategories, ({ one }) => ({ @@ -51,6 +53,8 @@ export const dishesToImages = pgTable( ], ); +export type IDishesToImages = typeof dishesToImages.$inferSelect; + export const dishesToImagesRelations = relations(dishesToImages, ({ one }) => ({ dish: one(dishes, { fields: [dishesToImages.dishId], diff --git a/src/@base/drizzle/schema/sessions.ts b/src/@base/drizzle/schema/sessions.ts index 7bf4bb1..9f95d33 100644 --- a/src/@base/drizzle/schema/sessions.ts +++ b/src/@base/drizzle/schema/sessions.ts @@ -1,14 +1,16 @@ import { relations } from "drizzle-orm"; -import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +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("previousId"), workerId: uuid("workerId").notNull(), httpAgent: text("httpAgent"), - ipAddress: text("ipAddress"), - token: text("token").notNull().unique(), + ip: text("ip"), + isActive: boolean("isActive").notNull().default(true), + onlineAt: timestamp("onlineAt").notNull().defaultNow(), refreshedAt: timestamp("refreshedAt").notNull().defaultNow(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), 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/user-agent.decorator.ts b/src/@core/decorators/user-agent.decorator.ts new file mode 100644 index 0000000..485eeb1 --- /dev/null +++ b/src/@core/decorators/user-agent.decorator.ts @@ -0,0 +1,20 @@ +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/interfaces/request.ts b/src/@core/interfaces/request.ts index 4998ec3..25eef6d 100644 --- a/src/@core/interfaces/request.ts +++ b/src/@core/interfaces/request.ts @@ -2,7 +2,29 @@ import { ISession } from "@postgress-db/schema/sessions"; import { IWorker } 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" + | "restaurantId" +>; + +export type RequestSession = Pick< + ISession, + "id" | "previousId" | "workerId" | "isActive" | "refreshedAt" +> & { + worker: RequestWorker | null; +}; + export interface Request extends Req { - worker?: Omit & { passwordHash: undefined }; - session?: ISession; + worker?: RequestWorker | null; + session?: RequestSession | null; } diff --git a/src/addresses/addresses.controller.ts b/src/addresses/addresses.controller.ts index 3cd38d5..eb38e64 100644 --- a/src/addresses/addresses.controller.ts +++ b/src/addresses/addresses.controller.ts @@ -7,13 +7,11 @@ import { ApiResponse, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { GetSuggestionsDto } from "./dto/get-suggestions.dto"; import { AddressSuggestion } from "./entities/suggestion.entity"; import { AddressesService } from "./services/addresses.service"; -@RequireSessionAuth() @Controller("addresses") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) diff --git a/src/app.module.ts b/src/app.module.ts index 0fd1a3e..bd63122 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,7 +20,6 @@ import { DrizzleModule } from "./@base/drizzle/drizzle.module"; import { AuthModule } from "./auth/auth.module"; import { SessionAuthGuard } from "./auth/guards/session-auth.guard"; import { RestaurantsModule } from "./restaurants/restaurants.module"; -import { SessionsService } from "./sessions/sessions.service"; import { WorkersModule } from "./workers/workers.module"; @Module({ @@ -64,7 +63,6 @@ import { WorkersModule } from "./workers/workers.module"; provide: APP_FILTER, useClass: AllExceptionsFilter, }, - SessionsService, { provide: APP_PIPE, useClass: ZodValidationPipe, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 49f1979..1c3e244 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,14 +1,22 @@ +import "dotenv/config"; 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 { WorkersModule } from "src/workers/workers.module"; import { AuthController } from "./controllers/auth.controller"; import { AuthService } from "./services/auth.service"; @Module({ - imports: [DrizzleModule, WorkersModule], - providers: [AuthService, SessionsService], + imports: [ + DrizzleModule, + WorkersModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + }), + ], + providers: [AuthService], controllers: [AuthController], + exports: [AuthService], }) export class AuthModule {} diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts index c2ac960..0b35ed5 100644 --- a/src/auth/auth.types.ts +++ b/src/auth/auth.types.ts @@ -1,3 +1,5 @@ +import { IWorker } from "@postgress-db/schema/workers"; + export enum AUTH_STRATEGY { accessToken = "access token", refreshToken = "refresh token", @@ -6,3 +8,15 @@ export enum AUTH_STRATEGY { export enum AUTH_COOKIES { token = "toite-auth-token", } + +export type SessionTokenPayload = { + sessionId: string; + workerId: string; + worker: Pick< + IWorker, + "name" | "restaurantId" | "login" | "role" | "isBlocked" + >; + httpAgent: string; + ip: string; + version: number; +}; diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index ac20ccc..e268641 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,17 +1,17 @@ -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 { Response } from "@core/interfaces/response"; import { Body, Delete, Get, - Headers, HttpCode, HttpStatus, - Ip, Post, + Res, } from "@nestjs/common"; import { ApiForbiddenResponse, @@ -24,8 +24,7 @@ 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 +32,6 @@ import { AuthService } from "../services/auth.service"; export class AuthController { constructor(private authService: AuthService) {} - @RequireSessionAuth() @Get("user") @HttpCode(HttpStatus.OK) @Serializable(WorkerEntity) @@ -51,8 +49,8 @@ export class AuthController { return worker; } + @Public() @Post("sign-in") - @SetCookies() @HttpCode(HttpStatus.OK) @Serializable(WorkerEntity) @ApiOperation({ @@ -67,26 +65,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: process.env.NODE_ENV === "production", + sameSite: "strict", + }); + return { ...worker, - setSessionToken: session?.token, }; } - @RequireSessionAuth() - @SetCookies() @Delete("sign-out") @HttpCode(HttpStatus.OK) @Serializable(class Empty {}) @@ -100,7 +104,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/guards/session-auth.guard.ts b/src/auth/guards/session-auth.guard.ts index 179e9bb..cc8283a 100644 --- a/src/auth/guards/session-auth.guard.ts +++ b/src/auth/guards/session-auth.guard.ts @@ -1,99 +1,99 @@ 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 { IWorker } from "@postgress-db/schema/workers"; -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 { AUTH_COOKIES } from "src/auth/auth.types"; +import { IS_PUBLIC_KEY } from "src/auth/decorators/public.decorator"; +import { AuthService } from "src/auth/services/auth.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 authService: AuthService, ) {} - 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 _handleSession(req: Request, res: Response) { + res; + const jwtSign = this.getCookie(req, AUTH_COOKIES.token); - if (!session) { - throw new UnauthorizedException(); - } + if (!jwtSign) throw new UnauthorizedException(); - const isCompromated = - session.ipAddress !== ip || session.httpAgent !== httpAgent; + const httpAgent = this.getUserAgent(req); + const ip = this.getUserIp(req); - if (isCompromated) { - // TODO: Implement logic for notification about compromated session - throw new UnauthorizedException("Session is compromated"); - } + const session = await this.authService.validateSession(jwtSign, { + httpAgent, + ip, + }); - const isTimeToRefresh = - new Date(session.refreshedAt).getTime() + - (ms.parse(process.env?.SESSION_EXPIRES_IN ?? "30m") ?? 0) < - new Date().getTime(); + if (!session) throw new UnauthorizedException(); + + req.session = session; + req.worker = session?.worker ?? null; - if (isTimeToRefresh) { - const newToken = await this.sessionsService.refresh(token); + const isRequireRefresh = this.authService.isSessionRequireRefresh(session); - res.cookie(AUTH_COOKIES.token, newToken, { - maxAge: ms.parse("1y"), + if (isRequireRefresh) { + const newSignedJWT = await this.authService.refreshSignedSession( + jwtSign, + { + httpAgent, + ip: ip ?? "N/A", + }, + ); + + res.cookie(AUTH_COOKIES.token, newSignedJWT, { + maxAge: 60 * 60 * 24 * 365, // 1 year httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", }); } - const worker = await this.workersService.findById(session.workerId); - - const isTimeToUpdateOnline = - !worker?.onlineAt || - new Date(worker.onlineAt).getTime() + (ms.parse("5m") ?? 0) < - new Date().getTime(); + return true; + } - if (isTimeToUpdateOnline && worker) { - 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 } as Omit< - IWorker, - "passwordHash" - > & { 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 8a98003..3620ac6 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,21 +1,51 @@ -import { IncomingHttpHeaders } from "http"; - +import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; import { UnauthorizedException } from "@core/errors/exceptions/unauthorized.exception"; -import { Injectable } from "@nestjs/common"; +import { Inject, Injectable } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { ISession, sessions } from "@postgress-db/schema/sessions"; +import { workers } from "@postgress-db/schema/workers"; import * as argon2 from "argon2"; -import { SessionsService } from "src/sessions/sessions.service"; +import { eq, or } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { SessionTokenPayload } from "src/auth/auth.types"; +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 { schema } from "test/helpers/db"; +import { v4 as uuidv4 } from "uuid"; -import { SignInDto } from "../dto/req/sign-in.dto"; +export const SESSION_TOKEN_GRACE_PERIOD = 60; // 1 minute +export const SESSION_TOKEN_REFRESH_INTERVAL = 60 * 15; // 15 minutes +export const SESSION_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 31; // 31 days @Injectable() export class AuthService { constructor( - private workersService: WorkersService, - private readonly sessionsService: SessionsService, + private readonly jwtService: JwtService, + private readonly workersService: WorkersService, + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} + private async _getWorker(workerId: string) { + const [worker] = await this.pg + .select({ + name: workers.name, + restaurantId: workers.restaurantId, + login: workers.login, + role: workers.role, + isBlocked: workers.isBlocked, + }) + .from(workers) + .where(eq(workers.id, workerId)); + + if (!worker) { + throw new BadRequestException(); + } + + return worker; + } + public async signIn(dto: SignInDto): Promise { const { login, password } = dto; @@ -33,31 +63,231 @@ export class AuthService { 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 { worker, httpAgent, ip } = data; - const httpAgent = String( - headers["user-agent"] || headers["User-Agent"] || "N/A", - ); + const { sessionId, signed } = await this.generateSignedSession({ + workerId: worker.id, + httpAgent, + ip, + }); - const created = await this.sessionsService.create({ + await this.pg.insert(sessions).values({ + id: sessionId, workerId: worker.id, + ip, httpAgent, - ipAddress, }); - if (!created) { - throw new UnauthorizedException("Failed to create session"); + 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(); } - return await this.sessionsService.findByToken(created.token); + const { sessionId, workerId } = decoded; + + const refreshed = await this.generateSignedSession({ + prevSign: signed, + workerId, + httpAgent, + ip, + }); + + // Update session in db + await this.pg + .update(sessions) + .set({ + id: refreshed.sessionId, + previousId: sessionId, + refreshedAt: new Date(), + }) + .where(eq(sessions.id, sessionId)); + + return refreshed.signed; } - public async destroySession(token: string) { - return await this.sessionsService.destroy(token); + 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; + }, + ) { + // Something wrong with the sign and that's bad + // We will not allow this session + if (!(await this.isSignValid(signed))) { + return false; + } + + const decoded = await this.decodeSession(signed); + + // If decoding is failed there is no sense to check anything + if (!decoded) { + return false; + } + + const { sessionId, httpAgent } = decoded; + + // Options is passing for additional validation of session + if (options) { + // If httpAgent is changed there is chance that session is compromised + if (!!options?.httpAgent && options.httpAgent !== httpAgent) { + return false; + } + } + + // Get session from db by sessionId that can be in id field or previousId field + const [session] = await this.pg + .select({ + id: sessions.id, + previousId: sessions.previousId, + workerId: sessions.workerId, + isActive: sessions.isActive, + refreshedAt: sessions.refreshedAt, + worker: { + id: workers.id, + name: workers.name, + login: workers.login, + role: workers.role, + isBlocked: workers.isBlocked, + hiredAt: workers.hiredAt, + firedAt: workers.firedAt, + onlineAt: workers.onlineAt, + createdAt: workers.createdAt, + updatedAt: workers.updatedAt, + restaurantId: workers.restaurantId, + }, + }) + .from(sessions) + .leftJoin(workers, eq(sessions.workerId, workers.id)) + .where(or(eq(sessions.id, sessionId), eq(sessions.previousId, sessionId))) + .limit(1); + + if (!session || !session.isActive) return false; + + if (session.id === sessionId) { + // Everything is ok + } else if (session.previousId === sessionId) { + // Handle session that was in grace period + if (!this.isDateInGracePeriod(session.refreshedAt)) { + return false; + } + } else { + // Session is invalid + return false; + } + + if (this.isSessionExpired(session)) { + return false; + } + + return session; } } diff --git a/src/dish-categories/dish-categories.controller.ts b/src/dish-categories/dish-categories.controller.ts index 9a80991..93f3e47 100644 --- a/src/dish-categories/dish-categories.controller.ts +++ b/src/dish-categories/dish-categories.controller.ts @@ -18,7 +18,6 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { DishCategoriesService } from "./dish-categories.service"; import { CreateDishCategoryDto } from "./dtos/create-dish-category.dto"; @@ -26,7 +25,6 @@ import { UpdateDishCategoryDto } from "./dtos/update-dish-category.dto"; import { DishCategoriesPaginatedDto } from "./entities/dish-categories-paginated.entity"; import { DishCategoryEntity } from "./entities/dish-category.entity"; -@RequireSessionAuth() @Controller("dish-categories") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) diff --git a/src/dishes/dishes.controller.ts b/src/dishes/dishes.controller.ts index 245c990..2087117 100644 --- a/src/dishes/dishes.controller.ts +++ b/src/dishes/dishes.controller.ts @@ -18,7 +18,6 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { DishesService } from "./dishes.service"; import { CreateDishDto } from "./dtos/create-dish.dto"; @@ -26,7 +25,6 @@ import { UpdateDishDto } from "./dtos/update-dish.dto"; import { DishEntity } from "./entities/dish.entity"; import { DishesPaginatedDto } from "./entities/dishes-paginated.entity"; -@RequireSessionAuth() @Controller("dishes") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) diff --git a/src/files/files.controller.ts b/src/files/files.controller.ts index bbc707a..cfd55c0 100644 --- a/src/files/files.controller.ts +++ b/src/files/files.controller.ts @@ -11,12 +11,10 @@ import { } from "@nestjs/swagger"; import { IWorker } from "@postgress-db/schema/workers"; import { FormDataRequest } from "nestjs-form-data"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.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"; -@RequireSessionAuth() @Controller("files") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) diff --git a/src/guests/guests.controller.ts b/src/guests/guests.controller.ts index a750852..81ce561 100644 --- a/src/guests/guests.controller.ts +++ b/src/guests/guests.controller.ts @@ -18,7 +18,6 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { CreateGuestDto } from "./dtos/create-guest.dto"; import { UpdateGuestDto } from "./dtos/update-guest.dto"; @@ -26,7 +25,6 @@ import { GuestEntity } from "./entities/guest.entity"; import { GuestsPaginatedDto } from "./entities/guests-paginated.entity"; import { GuestsService } from "./guests.service"; -@RequireSessionAuth() @Controller("guests") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts index f7ac805..13e9a79 100644 --- a/src/restaurants/@/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -15,7 +15,6 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; @@ -23,7 +22,6 @@ 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" }) diff --git a/src/restaurants/hours/restaurant-hours.controller.ts b/src/restaurants/hours/restaurant-hours.controller.ts index 1d2ed0d..441b3ca 100644 --- a/src/restaurants/hours/restaurant-hours.controller.ts +++ b/src/restaurants/hours/restaurant-hours.controller.ts @@ -9,7 +9,6 @@ import { ApiOperation, OmitType, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { CreateRestaurantHoursDto, @@ -23,7 +22,6 @@ export class CreateRestaurantHoursPayloadDto extends OmitType( ["restaurantId"] as const, ) {} -@RequireSessionAuth() @Controller("restaurants/:id/hours", { tags: ["restaurants"], }) diff --git a/src/restaurants/workshops/restaurant-workshops.controller.ts b/src/restaurants/workshops/restaurant-workshops.controller.ts index 8575a10..eb15cba 100644 --- a/src/restaurants/workshops/restaurant-workshops.controller.ts +++ b/src/restaurants/workshops/restaurant-workshops.controller.ts @@ -9,7 +9,6 @@ import { ApiOperation, OmitType, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { UpdateRestaurantWorkshopWorkersDto } from "./dto/put-restaurant-workshop-workers.dto"; import { WorkshopWorkerEntity } from "./entity/restaurant-workshop-worker.entity"; @@ -25,7 +24,6 @@ export class CreateRestaurantWorkshopPayloadDto extends OmitType( ["restaurantId"] as const, ) {} -@RequireSessionAuth() @Controller("restaurants/:id/workshops", { tags: ["restaurants"], }) diff --git a/src/sessions/dto/session-payload.ts b/src/sessions/dto/session-payload.ts deleted file mode 100644 index 7eac1f9..0000000 --- a/src/sessions/dto/session-payload.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { sessions } from "@postgress-db/schema/sessions"; -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 b17093c..0000000 --- a/src/sessions/sessions.service.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { UnauthorizedException } from "@core/errors/exceptions/unauthorized.exception"; -import { handleError } from "@core/errors/handleError"; -import { Inject, Injectable } from "@nestjs/common"; -import { schema } from "@postgress-db/drizzle.module"; -import { ISession, sessions } from "@postgress-db/schema/sessions"; -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 | undefined> { - try { - const { workerId, httpAgent, ipAddress } = dto; - const token = this.generateToken(); - - return await this.pg - .insert(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 || !session?.refreshedAt) 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); - return false; - } - } - - 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); - return false; - } - } -} diff --git a/src/timezones/timezones.controller.ts b/src/timezones/timezones.controller.ts index 7d0a480..bb96f2a 100644 --- a/src/timezones/timezones.controller.ts +++ b/src/timezones/timezones.controller.ts @@ -7,11 +7,9 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.decorator"; import { TimezonesListEntity } from "src/timezones/entities/timezones-list.entity"; import { TimezonesService } from "src/timezones/timezones.service"; -@RequireSessionAuth() @Controller("timezones") @ApiForbiddenResponse({ description: "Forbidden" }) @ApiUnauthorizedResponse({ description: "Unauthorized" }) diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index 75593ac..1328e0d 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -28,14 +28,12 @@ import { WorkerRole, workerRoleRank, } from "@postgress-db/schema/workers"; -import { RequireSessionAuth } from "src/auth/decorators/session-auth.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" }) diff --git a/yarn.lock b/yarn.lock index 9cae364..cd8d9a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2265,6 +2265,11 @@ "@smithy/types" "^4.1.0" tslib "^2.6.2" +"@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" @@ -3874,6 +3879,11 @@ dotenv@16.3.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== +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.30.1: version "0.30.1" resolved "https://registry.yarnpkg.com/drizzle-kit/-/drizzle-kit-0.30.1.tgz#79f000fdd96d837cc63d30b2e4b32d34c2cd4154" From 2aa51ed3484995d8641645ac8b11c4d1c60e0648 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 14 Jan 2025 15:10:56 +0200 Subject: [PATCH 029/180] refactor: split auth to sessions service --- src/auth/auth.module.ts | 5 +- src/auth/guards/session-auth.guard.ts | 11 +- src/auth/services/auth.service.ts | 238 +---------------------- src/auth/services/sessions.service.ts | 261 ++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 240 deletions(-) create mode 100644 src/auth/services/sessions.service.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 1c3e244..4081b79 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -6,6 +6,7 @@ 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: [ @@ -15,8 +16,8 @@ import { AuthService } from "./services/auth.service"; secret: process.env.JWT_SECRET, }), ], - providers: [AuthService], + providers: [AuthService, SessionsService], controllers: [AuthController], - exports: [AuthService], + exports: [AuthService, SessionsService], }) export class AuthModule {} diff --git a/src/auth/guards/session-auth.guard.ts b/src/auth/guards/session-auth.guard.ts index cc8283a..3d5458c 100644 --- a/src/auth/guards/session-auth.guard.ts +++ b/src/auth/guards/session-auth.guard.ts @@ -11,7 +11,7 @@ import { Reflector } from "@nestjs/core"; import * as requestIp from "@supercharge/request-ip"; import { AUTH_COOKIES } from "src/auth/auth.types"; import { IS_PUBLIC_KEY } from "src/auth/decorators/public.decorator"; -import { AuthService } from "src/auth/services/auth.service"; +import { SessionsService } from "src/auth/services/sessions.service"; @Injectable() export class SessionAuthGuard implements CanActivate { @@ -19,7 +19,7 @@ export class SessionAuthGuard implements CanActivate { constructor( private readonly reflector: Reflector, - private readonly authService: AuthService, + private readonly sessionsService: SessionsService, ) {} private getUserIp(req: Request) { @@ -51,7 +51,7 @@ export class SessionAuthGuard implements CanActivate { const httpAgent = this.getUserAgent(req); const ip = this.getUserIp(req); - const session = await this.authService.validateSession(jwtSign, { + const session = await this.sessionsService.validateSession(jwtSign, { httpAgent, ip, }); @@ -61,10 +61,11 @@ export class SessionAuthGuard implements CanActivate { req.session = session; req.worker = session?.worker ?? null; - const isRequireRefresh = this.authService.isSessionRequireRefresh(session); + const isRequireRefresh = + this.sessionsService.isSessionRequireRefresh(session); if (isRequireRefresh) { - const newSignedJWT = await this.authService.refreshSignedSession( + const newSignedJWT = await this.sessionsService.refreshSignedSession( jwtSign, { httpAgent, diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 3620ac6..cd749e0 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,51 +1,23 @@ -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 { ISession, sessions } from "@postgress-db/schema/sessions"; -import { workers } from "@postgress-db/schema/workers"; import * as argon2 from "argon2"; -import { eq, or } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { SessionTokenPayload } from "src/auth/auth.types"; 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 { schema } from "test/helpers/db"; -import { v4 as uuidv4 } from "uuid"; -export const SESSION_TOKEN_GRACE_PERIOD = 60; // 1 minute -export const SESSION_TOKEN_REFRESH_INTERVAL = 60 * 15; // 15 minutes -export const SESSION_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 31; // 31 days +import { SessionsService } from "./sessions.service"; @Injectable() export class AuthService { constructor( - private readonly jwtService: JwtService, private readonly workersService: WorkersService, + private readonly sessionsService: SessionsService, @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} - private async _getWorker(workerId: string) { - const [worker] = await this.pg - .select({ - name: workers.name, - restaurantId: workers.restaurantId, - login: workers.login, - role: workers.role, - isBlocked: workers.isBlocked, - }) - .from(workers) - .where(eq(workers.id, workerId)); - - if (!worker) { - throw new BadRequestException(); - } - - return worker; - } - public async signIn(dto: SignInDto): Promise { const { login, password } = dto; @@ -68,22 +40,7 @@ export class AuthService { 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; + return this.sessionsService.createSignedSession(data); } public async refreshSignedSession( @@ -93,124 +50,7 @@ export class AuthService { 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, - }); - - // Update session in db - 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 - ); + return this.sessionsService.refreshSignedSession(signed, options); } public async validateSession( @@ -220,74 +60,6 @@ export class AuthService { ip?: string; }, ) { - // Something wrong with the sign and that's bad - // We will not allow this session - if (!(await this.isSignValid(signed))) { - return false; - } - - const decoded = await this.decodeSession(signed); - - // If decoding is failed there is no sense to check anything - if (!decoded) { - return false; - } - - const { sessionId, httpAgent } = decoded; - - // Options is passing for additional validation of session - if (options) { - // If httpAgent is changed there is chance that session is compromised - if (!!options?.httpAgent && options.httpAgent !== httpAgent) { - return false; - } - } - - // Get session from db by sessionId that can be in id field or previousId field - const [session] = await this.pg - .select({ - id: sessions.id, - previousId: sessions.previousId, - workerId: sessions.workerId, - isActive: sessions.isActive, - refreshedAt: sessions.refreshedAt, - worker: { - id: workers.id, - name: workers.name, - login: workers.login, - role: workers.role, - isBlocked: workers.isBlocked, - hiredAt: workers.hiredAt, - firedAt: workers.firedAt, - onlineAt: workers.onlineAt, - createdAt: workers.createdAt, - updatedAt: workers.updatedAt, - restaurantId: workers.restaurantId, - }, - }) - .from(sessions) - .leftJoin(workers, eq(sessions.workerId, workers.id)) - .where(or(eq(sessions.id, sessionId), eq(sessions.previousId, sessionId))) - .limit(1); - - if (!session || !session.isActive) return false; - - if (session.id === sessionId) { - // Everything is ok - } else if (session.previousId === sessionId) { - // Handle session that was in grace period - if (!this.isDateInGracePeriod(session.refreshedAt)) { - return false; - } - } else { - // Session is invalid - return false; - } - - if (this.isSessionExpired(session)) { - return false; - } - - return session; + return this.sessionsService.validateSession(signed, options); } } diff --git a/src/auth/services/sessions.service.ts b/src/auth/services/sessions.service.ts new file mode 100644 index 0000000..614e3f1 --- /dev/null +++ b/src/auth/services/sessions.service.ts @@ -0,0 +1,261 @@ +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, or } 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 = 60; // 1 minute +export const SESSION_TOKEN_REFRESH_INTERVAL = 60 * 15; // 15 minutes +export const SESSION_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 31; // 31 days + +@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 + .select({ + name: workers.name, + restaurantId: workers.restaurantId, + login: workers.login, + role: workers.role, + isBlocked: workers.isBlocked, + }) + .from(workers) + .where(eq(workers.id, workerId)); + + 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 + .select({ + id: sessions.id, + previousId: sessions.previousId, + workerId: sessions.workerId, + isActive: sessions.isActive, + refreshedAt: sessions.refreshedAt, + worker: { + id: workers.id, + name: workers.name, + login: workers.login, + role: workers.role, + isBlocked: workers.isBlocked, + hiredAt: workers.hiredAt, + firedAt: workers.firedAt, + onlineAt: workers.onlineAt, + createdAt: workers.createdAt, + updatedAt: workers.updatedAt, + restaurantId: workers.restaurantId, + }, + }) + .from(sessions) + .leftJoin(workers, eq(sessions.workerId, workers.id)) + .where(or(eq(sessions.id, sessionId), eq(sessions.previousId, sessionId))) + .limit(1); + + 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; + } +} From 63cc35db39e9d15f5ff187a1d3c1f329b77bafff Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 14 Jan 2025 15:43:59 +0200 Subject: [PATCH 030/180] refactor: env --- src/@base/drizzle/clear.ts | 5 +- src/@base/drizzle/drizzle.module.ts | 3 +- src/@base/drizzle/migrate.ts | 3 +- src/@base/redis/redis.utils.ts | 5 +- src/@base/s3/s3.service.ts | 11 +-- src/@core/env.ts | 86 ++++++++++++++++++++++++ src/addresses/services/dadata.service.ts | 3 +- src/addresses/services/google.service.ts | 3 +- src/app.module.ts | 3 +- src/auth/auth.module.ts | 3 +- src/auth/controllers/auth.controller.ts | 3 +- src/auth/guards/session-auth.guard.ts | 3 +- src/auth/services/sessions.service.ts | 7 +- src/main.ts | 13 ++-- 14 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 src/@core/env.ts diff --git a/src/@base/drizzle/clear.ts b/src/@base/drizzle/clear.ts index bbb8da4..f66e2f9 100644 --- a/src/@base/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); diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index 2f25c0c..21f5cf9 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -1,3 +1,4 @@ +import env from "@core/env"; import { Module } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; @@ -38,7 +39,7 @@ export type Schema = typeof schema; const connectionString = configService.get("POSTGRESQL_URL"); const pool = new Pool({ connectionString, - ssl: process.env.NODE_ENV === "production" ? true : false, + ssl: env.NODE_ENV === "production" ? true : false, }); return drizzle(pool, { schema }) as NodePgDatabase; diff --git a/src/@base/drizzle/migrate.ts b/src/@base/drizzle/migrate.ts index 288d9a9..7516e9a 100644 --- a/src/@base/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/@base/redis/redis.utils.ts b/src/@base/redis/redis.utils.ts index c12a37f..bc22a56 100644 --- a/src/@base/redis/redis.utils.ts +++ b/src/@base/redis/redis.utils.ts @@ -1,10 +1,11 @@ +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 env = process.env.NODE_ENV; - const keyParts = [appName, version, env]; + const keyParts = [appName, version, env.NODE_ENV]; if (typeof key === "string") { keyParts.push(key); diff --git a/src/@base/s3/s3.service.ts b/src/@base/s3/s3.service.ts index 89efc1b..cf532bc 100644 --- a/src/@base/s3/s3.service.ts +++ b/src/@base/s3/s3.service.ts @@ -6,6 +6,7 @@ import { 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"; @@ -22,11 +23,11 @@ export class S3Service { private _getEnvs() { return { - region: process.env.S3_REGION as string, - accessKeyId: process.env.S3_ACCESS_KEY_ID as string, - secretAccessKey: process.env.S3_SECRET_ACCESS_KEY as string, - endpoint: process.env.S3_ENDPOINT as string, - bucketName: process.env.S3_BUCKET_NAME as string, + 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, }; } diff --git a/src/@core/env.ts b/src/@core/env.ts new file mode 100644 index 0000000..fe31394 --- /dev/null +++ b/src/@core/env.ts @@ -0,0 +1,86 @@ +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.number().default(60), // Default 1 minute + REFRESH_INTERVAL: z.number().default(60 * 15), // Default 15 minutes + EXPIRES_IN: z.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(), + }), +}); + +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, + }, +}); + +export default env; diff --git a/src/addresses/services/dadata.service.ts b/src/addresses/services/dadata.service.ts index aa0e89c..76f98e7 100644 --- a/src/addresses/services/dadata.service.ts +++ b/src/addresses/services/dadata.service.ts @@ -1,3 +1,4 @@ +import env from "@core/env"; import { HttpService } from "@nestjs/axios"; import { Injectable } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; @@ -15,7 +16,7 @@ interface DadataResponse { export class DadataService { private readonly API_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"; - private readonly API_TOKEN = process.env.DADATA_API_TOKEN; + private readonly API_TOKEN = env.DADATA_API_TOKEN; constructor(private readonly httpService: HttpService) {} diff --git a/src/addresses/services/google.service.ts b/src/addresses/services/google.service.ts index 0260c6f..f1a679d 100644 --- a/src/addresses/services/google.service.ts +++ b/src/addresses/services/google.service.ts @@ -1,3 +1,4 @@ +import env from "@core/env"; import { HttpService } from "@nestjs/axios"; import { Injectable } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; @@ -22,7 +23,7 @@ interface GoogleGeocodingResponse { export class GoogleService { private readonly API_URL = "https://maps.googleapis.com/maps/api/geocode/json"; - private readonly API_KEY = process.env.GOOGLE_MAPS_API_KEY; + private readonly API_KEY = env.GOOGLE_MAPS_API_KEY; constructor(private readonly httpService: HttpService) {} diff --git a/src/app.module.ts b/src/app.module.ts index bd63122..7bdba04 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,4 @@ +import env from "@core/env"; import { AllExceptionsFilter } from "@core/errors/filter"; import { RolesGuard } from "@core/guards/roles.guard"; import { RedisModule } from "@liaoliaots/nestjs-redis"; @@ -43,7 +44,7 @@ import { WorkersModule } from "./workers/workers.module"; }), RedisModule.forRoot({ config: { - url: process.env.REDIS_URL, + url: env.REDIS_URL, }, }), TimezonesModule, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 4081b79..4919e89 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,4 +1,5 @@ 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"; @@ -13,7 +14,7 @@ import { SessionsService } from "./services/sessions.service"; DrizzleModule, WorkersModule, JwtModule.register({ - secret: process.env.JWT_SECRET, + secret: env.JWT.SECRET, }), ], providers: [AuthService, SessionsService], diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index e268641..340ea04 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -3,6 +3,7 @@ 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 { Response } from "@core/interfaces/response"; import { Body, @@ -82,7 +83,7 @@ export class AuthController { res.cookie(AUTH_COOKIES.token, signedJWT, { maxAge: 60 * 60 * 24 * 365, // 1 year httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: env.NODE_ENV === "production", sameSite: "strict", }); diff --git a/src/auth/guards/session-auth.guard.ts b/src/auth/guards/session-auth.guard.ts index 3d5458c..43891b5 100644 --- a/src/auth/guards/session-auth.guard.ts +++ b/src/auth/guards/session-auth.guard.ts @@ -1,3 +1,4 @@ +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"; @@ -76,7 +77,7 @@ export class SessionAuthGuard implements CanActivate { res.cookie(AUTH_COOKIES.token, newSignedJWT, { maxAge: 60 * 60 * 24 * 365, // 1 year httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: env.NODE_ENV === "production", sameSite: "strict", }); } diff --git a/src/auth/services/sessions.service.ts b/src/auth/services/sessions.service.ts index 614e3f1..2420d7d 100644 --- a/src/auth/services/sessions.service.ts +++ b/src/auth/services/sessions.service.ts @@ -1,3 +1,4 @@ +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"; @@ -11,9 +12,9 @@ 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 = 60; // 1 minute -export const SESSION_TOKEN_REFRESH_INTERVAL = 60 * 15; // 15 minutes -export const SESSION_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 31; // 31 days +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 { diff --git a/src/main.ts b/src/main.ts index 27c2d5e..644b63d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,6 @@ +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 { schema } from "@postgress-db/drizzle.module"; @@ -12,15 +14,14 @@ 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 }, - ); + const db = drizzle(new Pool({ connectionString: 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 ?? "123456"), + passwordHash: await hash(env.INITIAL_ADMIN_PASSWORD ?? "123456"), role: schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, }); } @@ -54,7 +55,7 @@ async function bootstrap() { swaggerOptions: {}, }); - await app.listen(process.env?.PORT ?? 6701); + await app.listen(env.PORT); } createUserIfDbEmpty(); From df46b83f5dbe28e48a23eedd36c90bf93d5637cd Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 14 Jan 2025 17:52:48 +0200 Subject: [PATCH 031/180] feat: nestjs-i18n --- .eslintrc.js | 33 +++++- nest-cli.json | 7 ++ package.json | 2 + src/@core/config/app.ts | 16 ++- src/@core/decorators/is-phone.decorator.ts | 2 +- .../decorators/is-time-format.decorator.ts | 2 +- src/@core/dto/pagination-meta.dto.ts | 2 +- src/@core/dto/pagination-response.entity.ts | 2 +- src/@core/errors/filter.ts | 1 + src/@core/pipes/no-omit-validation.pipe.ts | 2 +- src/@core/pipes/validation.pipe.ts | 2 +- src/addresses/dto/get-suggestions.dto.ts | 6 +- src/addresses/entities/suggestion.entity.ts | 2 +- src/app.module.ts | 20 ++++ src/auth/dto/req/sign-in.dto.ts | 2 +- .../entities/dish-category.entity.ts | 8 +- src/dishes/entities/dish.entity.ts | 8 +- src/files/entitites/file.entity.ts | 8 +- src/guests/entities/guest.entity.ts | 2 +- src/i18n/messages/en/validation.json | 22 ++++ src/i18n/messages/et/validation.json | 22 ++++ src/i18n/messages/ru/validation.json | 22 ++++ src/i18n/validators/index.ts | 108 ++++++++++++++++++ .../@/entities/restaurant.entity.ts | 8 +- .../hours/entities/restaurant-hours.entity.ts | 8 +- .../put-restaurant-workshop-workers.dto.ts | 4 +- .../entity/restaurant-workshop.entity.ts | 2 +- .../entity/workshop-worker.entity.ts | 2 +- .../entities/timezones-list.entity.ts | 2 +- src/workers/dto/req/put-worker.dto.ts | 2 +- src/workers/entities/worker.entity.ts | 8 +- tsconfig.json | 1 + yarn.lock | 31 ++++- 33 files changed, 319 insertions(+), 50 deletions(-) create mode 100644 src/i18n/messages/en/validation.json create mode 100644 src/i18n/messages/et/validation.json create mode 100644 src/i18n/messages/ru/validation.json create mode 100644 src/i18n/validators/index.ts 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/nest-cli.json b/nest-cli.json index f9aa683..f480375 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/src" + } + ], "deleteOutDir": true } } diff --git a/package.json b/package.json index 0926818..eab1358 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "mongoose": "^8.2.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", @@ -83,6 +84,7 @@ "@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", diff --git a/src/@core/config/app.ts b/src/@core/config/app.ts index 82b1414..b2f7441 100644 --- a/src/@core/config/app.ts +++ b/src/@core/config/app.ts @@ -1,15 +1,21 @@ import { INestApplication } from "@nestjs/common"; import * as cookieParser from "cookie-parser"; +import { I18nValidationExceptionFilter, I18nValidationPipe } from "nestjs-i18n"; export const configApp = (app: INestApplication) => { // Parse cookies app.use(cookieParser()); - // app.useGlobalPipes( - // new ValidationPipe({ - // transform: true, // Transform is recomended configuration for avoind issues with arrays of files transformations - // }), - // ); + app.useGlobalPipes( + // new ValidationPipe({ + // transform: true, // Transform is recomended configuration for avoind issues with arrays of files transformations + // }), + new I18nValidationPipe({ + transform: true, + }), + ); + + app.useGlobalFilters(new I18nValidationExceptionFilter()); // Enable CORS app.enableCors({ diff --git a/src/@core/decorators/is-phone.decorator.ts b/src/@core/decorators/is-phone.decorator.ts index f00e24a..e04b32f 100644 --- a/src/@core/decorators/is-phone.decorator.ts +++ b/src/@core/decorators/is-phone.decorator.ts @@ -2,7 +2,7 @@ import { registerDecorator, ValidationArguments, ValidationOptions, -} from "class-validator"; +} from "@i18n-class-validator"; import { isValidPhoneNumber } from "libphonenumber-js"; export function IsPhoneNumber(validationOptions?: ValidationOptions) { diff --git a/src/@core/decorators/is-time-format.decorator.ts b/src/@core/decorators/is-time-format.decorator.ts index 95851d9..dfd0f3b 100644 --- a/src/@core/decorators/is-time-format.decorator.ts +++ b/src/@core/decorators/is-time-format.decorator.ts @@ -2,7 +2,7 @@ import { registerDecorator, ValidationArguments, ValidationOptions, -} from "class-validator"; +} from "@i18n-class-validator"; export function IsTimeFormat(validationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { 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/errors/filter.ts b/src/@core/errors/filter.ts index 5c5ffda..09dd1ab 100644 --- a/src/@core/errors/filter.ts +++ b/src/@core/errors/filter.ts @@ -24,6 +24,7 @@ export class AllExceptionsFilter implements ExceptionFilter { const uri = new URL("http://localhost" + url); const errorCategory = slugify(uri.pathname.split("/")?.[1]).toUpperCase(); + console.log(response, exception); httpAdapter.reply( ctx.getResponse(), diff --git a/src/@core/pipes/no-omit-validation.pipe.ts b/src/@core/pipes/no-omit-validation.pipe.ts index aacb636..edf5ebf 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 { 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 57b0cc5..93cd83d 100644 --- a/src/@core/pipes/validation.pipe.ts +++ b/src/@core/pipes/validation.pipe.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/ban-types */ import { FormException } from "@core/errors/exceptions/form.exception"; import { handleError } from "@core/errors/handleError"; +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"; diff --git a/src/addresses/dto/get-suggestions.dto.ts b/src/addresses/dto/get-suggestions.dto.ts index 0663b5e..ae709b0 100644 --- a/src/addresses/dto/get-suggestions.dto.ts +++ b/src/addresses/dto/get-suggestions.dto.ts @@ -1,12 +1,12 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Expose, Transform } from "class-transformer"; import { IsBoolean, IsEnum, IsOptional, IsString, MinLength, -} from "class-validator"; +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose, Transform } from "class-transformer"; import { AddressProvider } from "../entities/suggestion.entity"; diff --git a/src/addresses/entities/suggestion.entity.ts b/src/addresses/entities/suggestion.entity.ts index 0f50af2..aba970c 100644 --- a/src/addresses/entities/suggestion.entity.ts +++ b/src/addresses/entities/suggestion.entity.ts @@ -1,6 +1,6 @@ +import { IsOptional } from "@i18n-class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsOptional } from "class-validator"; export interface IGoogleRawResult { address_components: { diff --git a/src/app.module.ts b/src/app.module.ts index 7bdba04..ca76ae8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,5 @@ +import * as path from "path"; + import env from "@core/env"; import { AllExceptionsFilter } from "@core/errors/filter"; import { RolesGuard } from "@core/guards/roles.guard"; @@ -8,6 +10,12 @@ import { APP_FILTER, APP_GUARD, 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 { S3Module } from "src/@base/s3/s3.module"; import { AddressesModule } from "src/addresses/addresses.module"; @@ -58,6 +66,18 @@ import { WorkersModule } from "./workers/workers.module"; S3Module, FilesModule, NestjsFormDataModule, + I18nModule.forRoot({ + fallbackLanguage: "en", + loaderOptions: { + path: path.join(__dirname, "/i18n/messages/"), + watch: true, + }, + resolvers: [ + { use: QueryResolver, options: ["lang"] }, + new HeaderResolver(["x-lang"]), + AcceptLanguageResolver, + ], + }), ], providers: [ { diff --git a/src/auth/dto/req/sign-in.dto.ts b/src/auth/dto/req/sign-in.dto.ts index 5ac3360..c1cc153 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({ diff --git a/src/dish-categories/entities/dish-category.entity.ts b/src/dish-categories/entities/dish-category.entity.ts index 07456d4..c1db14b 100644 --- a/src/dish-categories/entities/dish-category.entity.ts +++ b/src/dish-categories/entities/dish-category.entity.ts @@ -1,13 +1,13 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IDishCategory } from "@postgress-db/schema/dish-categories"; -import { Expose } from "class-transformer"; import { IsBoolean, IsISO8601, IsNumber, IsString, IsUUID, -} from "class-validator"; +} 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() diff --git a/src/dishes/entities/dish.entity.ts b/src/dishes/entities/dish.entity.ts index d9f98dc..c6b2766 100644 --- a/src/dishes/entities/dish.entity.ts +++ b/src/dishes/entities/dish.entity.ts @@ -1,6 +1,3 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IDish, ZodWeightMeasureEnum } from "@postgress-db/schema/dishes"; -import { Expose } from "class-transformer"; import { IsBoolean, IsEnum, @@ -8,7 +5,10 @@ import { IsNumber, IsString, IsUUID, -} from "class-validator"; +} from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IDish, ZodWeightMeasureEnum } from "@postgress-db/schema/dishes"; +import { Expose } from "class-transformer"; export class DishEntity implements IDish { @Expose() diff --git a/src/files/entitites/file.entity.ts b/src/files/entitites/file.entity.ts index 1f0bb26..2268d18 100644 --- a/src/files/entitites/file.entity.ts +++ b/src/files/entitites/file.entity.ts @@ -1,13 +1,13 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IFile } from "@postgress-db/schema/files"; -import { Expose } from "class-transformer"; import { IsISO8601, IsNumber, IsOptional, IsString, IsUUID, -} from "class-validator"; +} 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() diff --git a/src/guests/entities/guest.entity.ts b/src/guests/entities/guest.entity.ts index ffccf86..6988c40 100644 --- a/src/guests/entities/guest.entity.ts +++ b/src/guests/entities/guest.entity.ts @@ -1,8 +1,8 @@ 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"; -import { IsISO8601, IsNumber, IsString, IsUUID } from "class-validator"; export class GuestEntity implements IGuest { @Expose() diff --git a/src/i18n/messages/en/validation.json b/src/i18n/messages/en/validation.json new file mode 100644 index 0000000..06f7614 --- /dev/null +++ b/src/i18n/messages/en/validation.json @@ -0,0 +1,22 @@ +{ + "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" + } +} diff --git a/src/i18n/messages/et/validation.json b/src/i18n/messages/et/validation.json new file mode 100644 index 0000000..815644e --- /dev/null +++ b/src/i18n/messages/et/validation.json @@ -0,0 +1,22 @@ +{ + "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" + } +} diff --git a/src/i18n/messages/ru/validation.json b/src/i18n/messages/ru/validation.json new file mode 100644 index 0000000..a11544b --- /dev/null +++ b/src/i18n/messages/ru/validation.json @@ -0,0 +1,22 @@ +{ + "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": "Должно быть действительной широтой" + } +} diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts new file mode 100644 index 0000000..c915a04 --- /dev/null +++ b/src/i18n/validators/index.ts @@ -0,0 +1,108 @@ +import { applyDecorators } from "@nestjs/common"; +// eslint-disable-next-line no-restricted-imports +import { + IsArray as _IsArray, + IsBoolean as _IsBoolean, + IsDate as _IsDate, + IsEnum as _IsEnum, + IsISO8601 as _IsISO8601, + IsLatitude as _IsLatitude, + IsNumber as _IsNumber, + IsOptional as _IsOptional, + IsString as _IsString, + IsStrongPassword as _IsStrongPassword, + IsUUID as _IsUUID, + MinLength as _MinLength, + IsNumberOptions, + ValidationOptions, +} from "class-validator"; +import { i18nValidationMessage } from "nestjs-i18n"; + +// eslint-disable-next-line no-restricted-imports +export { + validate, + registerDecorator, + ValidationArguments, +} 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 IsString = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsString(mergeI18nValidation("isString", validationOptions)), + ); + +export const IsBoolean = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsBoolean(mergeI18nValidation("isBoolean", 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), + ), + ); diff --git a/src/restaurants/@/entities/restaurant.entity.ts b/src/restaurants/@/entities/restaurant.entity.ts index 5ac0297..abc91fa 100644 --- a/src/restaurants/@/entities/restaurant.entity.ts +++ b/src/restaurants/@/entities/restaurant.entity.ts @@ -1,6 +1,3 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IRestaurant } from "@postgress-db/schema/restaurants"; -import { Expose } from "class-transformer"; import { IsBoolean, IsISO8601, @@ -8,7 +5,10 @@ import { IsOptional, IsString, IsUUID, -} from "class-validator"; +} from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IRestaurant } from "@postgress-db/schema/restaurants"; +import { Expose } from "class-transformer"; export class RestaurantEntity implements IRestaurant { @IsUUID() diff --git a/src/restaurants/hours/entities/restaurant-hours.entity.ts b/src/restaurants/hours/entities/restaurant-hours.entity.ts index ea6d362..0011491 100644 --- a/src/restaurants/hours/entities/restaurant-hours.entity.ts +++ b/src/restaurants/hours/entities/restaurant-hours.entity.ts @@ -1,15 +1,15 @@ import { IsTimeFormat } from "@core/decorators/is-time-format.decorator"; import { DayOfWeek, DayOfWeekEnum } from "@core/types/general"; -import { ApiProperty, OmitType, PartialType, PickType } from "@nestjs/swagger"; -import { IRestaurantHours } from "@postgress-db/schema/restaurants"; -import { Expose } from "class-transformer"; import { IsBoolean, IsEnum, IsISO8601, IsString, IsUUID, -} from "class-validator"; +} from "@i18n-class-validator"; +import { ApiProperty, OmitType, PartialType, PickType } from "@nestjs/swagger"; +import { IRestaurantHours } from "@postgress-db/schema/restaurants"; +import { Expose } from "class-transformer"; export class RestaurantHoursEntity implements IRestaurantHours { @IsUUID() diff --git a/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts b/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts index 196b8d8..fbb4fba 100644 --- a/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts +++ b/src/restaurants/workshops/dto/put-restaurant-workshop-workers.dto.ts @@ -1,11 +1,11 @@ +import { IsArray, IsUUID } from "@i18n-class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsArray, IsUUID } from "class-validator"; export class UpdateRestaurantWorkshopWorkersDto { @Expose() @IsArray() - @IsUUID(4, { each: true }) + @IsUUID("4", { each: true }) @ApiProperty({ description: "Array of worker IDs to assign to the workshop", example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], diff --git a/src/restaurants/workshops/entity/restaurant-workshop.entity.ts b/src/restaurants/workshops/entity/restaurant-workshop.entity.ts index 6d6a675..e04c247 100644 --- a/src/restaurants/workshops/entity/restaurant-workshop.entity.ts +++ b/src/restaurants/workshops/entity/restaurant-workshop.entity.ts @@ -1,7 +1,7 @@ +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"; -import { IsBoolean, IsISO8601, IsString, IsUUID } from "class-validator"; export class RestaurantWorkshopDto implements IRestaurantWorkshop { @IsUUID() diff --git a/src/restaurants/workshops/entity/workshop-worker.entity.ts b/src/restaurants/workshops/entity/workshop-worker.entity.ts index 0f0abb0..4e1c97f 100644 --- a/src/restaurants/workshops/entity/workshop-worker.entity.ts +++ b/src/restaurants/workshops/entity/workshop-worker.entity.ts @@ -1,7 +1,7 @@ +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"; -import { IsISO8601, IsUUID } from "class-validator"; export class WorkshopWorkerDto implements IWorkshopWorker { @IsUUID() diff --git a/src/timezones/entities/timezones-list.entity.ts b/src/timezones/entities/timezones-list.entity.ts index 39b792d..40b3b32 100644 --- a/src/timezones/entities/timezones-list.entity.ts +++ b/src/timezones/entities/timezones-list.entity.ts @@ -1,6 +1,6 @@ +import { IsArray, IsString } from "@i18n-class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsArray, IsString } from "class-validator"; export class TimezonesListEntity { @Expose() diff --git a/src/workers/dto/req/put-worker.dto.ts b/src/workers/dto/req/put-worker.dto.ts index 10f8069..1e1900b 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,7 +6,6 @@ import { PickType, } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsDate, IsOptional, IsStrongPassword } from "class-validator"; import { WorkerEntity } from "src/workers/entities/worker.entity"; export class CreateWorkerDto extends IntersectionType( diff --git a/src/workers/entities/worker.entity.ts b/src/workers/entities/worker.entity.ts index 5d6f0b8..3eeb28a 100644 --- a/src/workers/entities/worker.entity.ts +++ b/src/workers/entities/worker.entity.ts @@ -1,6 +1,3 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IWorker, ZodWorkerRole } from "@postgress-db/schema/workers"; -import { Exclude, Expose } from "class-transformer"; import { IsBoolean, IsEnum, @@ -9,7 +6,10 @@ import { IsString, IsUUID, MinLength, -} from "class-validator"; +} from "@i18n-class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IWorker, ZodWorkerRole } from "@postgress-db/schema/workers"; +import { Exclude, Expose } from "class-transformer"; export class WorkerEntity implements IWorker { @IsUUID() diff --git a/tsconfig.json b/tsconfig.json index c6861e7..50c5fcd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "noFallthroughCasesInSwitch": true, "paths": { "@core/*": ["src/@core/*"], + "@i18n-class-validator": ["src/i18n/validators"], "@postgress-db/*": ["src/@base/drizzle/*"], }, }, diff --git a/yarn.lock b/yarn.lock index cd8d9a3..081d3a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2577,6 +2577,11 @@ 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" @@ -2833,6 +2838,11 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +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.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -3643,7 +3653,7 @@ 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== @@ -5294,7 +5304,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== @@ -6232,6 +6242,18 @@ nestjs-form-data@^1.9.92: mkdirp "^1.0.4" type-is "^1.6.18" +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" @@ -7252,6 +7274,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" From d66498cf7df2eeaffbd3f304c66be6dbd43859e3 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 15 Jan 2025 19:23:16 +0200 Subject: [PATCH 032/180] fix: env coerce --- src/@core/env.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/@core/env.ts b/src/@core/env.ts index fe31394..4108664 100644 --- a/src/@core/env.ts +++ b/src/@core/env.ts @@ -30,9 +30,9 @@ export const envSchema = z.object({ // JWT // JWT: z.object({ SECRET: z.string(), - GRACE_PERIOD: z.number().default(60), // Default 1 minute - REFRESH_INTERVAL: z.number().default(60 * 15), // Default 15 minutes - EXPIRES_IN: z.number().default(60 * 60 * 24 * 31), // Default 31 days + 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 // From 88f174ab2301d902497beba1be311c524c94f326 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 15 Jan 2025 19:23:30 +0200 Subject: [PATCH 033/180] feat: link images to the dishes --- src/dishes/dishes.service.ts | 89 +++++++++++++++--------- src/dishes/entities/dish-image.entity.ts | 14 ++++ src/dishes/entities/dish.entity.ts | 10 +++ src/i18n/validators/index.ts | 1 + 4 files changed, 83 insertions(+), 31 deletions(-) create mode 100644 src/dishes/entities/dish-image.entity.ts diff --git a/src/dishes/dishes.service.ts b/src/dishes/dishes.service.ts index 00b1eaf..ea242b2 100644 --- a/src/dishes/dishes.service.ts +++ b/src/dishes/dishes.service.ts @@ -44,23 +44,41 @@ export class DishesService { }): Promise { const { pagination, sorting, filters } = options ?? {}; - const query = this.pg.select().from(schema.dishes); - - if (filters) { - query.where(DrizzleUtils.buildFilterConditions(schema.dishes, 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); + 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 query = this.pg.query.dishes.findMany({ + where, + with: { + dishesToImages: { + with: { + imageFile: true, + }, + }, + }, + orderBy, + limit: pagination?.size ?? PAGINATION_DEFAULT_LIMIT, + offset: pagination?.offset ?? 0, + }); + + const result = await query; + + return result.map((dish) => ({ + ...dish, + images: dish.dishesToImages.map((di) => ({ + ...di.imageFile, + sortIndex: di.sortIndex, + })), + })); } public async create(dto: CreateDishDto): Promise { @@ -76,7 +94,7 @@ export class DishesService { throw new ServerErrorException("Failed to create dish"); } - return dish; + return { ...dish, images: [] }; } public async update( @@ -94,22 +112,31 @@ export class DishesService { .set(dto) .where(eq(schema.dishes.id, id)); - const result = await this.pg - .select() - .from(schema.dishes) - .where(eq(schema.dishes.id, id)) - .limit(1); - - return result[0]; + return this.findById(id); } public async findById(id: string): Promise { - const result = await this.pg - .select() - .from(schema.dishes) - .where(eq(schema.dishes.id, id)) - .limit(1); + 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[0]; + return { + ...result, + images: result.dishesToImages.map((di) => ({ + ...di.imageFile, + sortIndex: di.sortIndex, + })), + }; } } diff --git a/src/dishes/entities/dish-image.entity.ts b/src/dishes/entities/dish-image.entity.ts new file mode 100644 index 0000000..41b190a --- /dev/null +++ b/src/dishes/entities/dish-image.entity.ts @@ -0,0 +1,14 @@ +import { IsNumber } 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() + @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 index c6b2766..12f7826 100644 --- a/src/dishes/entities/dish.entity.ts +++ b/src/dishes/entities/dish.entity.ts @@ -5,10 +5,12 @@ import { IsNumber, IsString, IsUUID, + ValidateNested, } from "@i18n-class-validator"; import { ApiProperty } 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() @@ -101,6 +103,14 @@ export class DishEntity implements IDish { }) isPublishedAtSite: boolean; + @Expose() + @ValidateNested() + @ApiProperty({ + description: "Images associated with the dish", + type: [DishImageEntity], + }) + images: DishImageEntity[]; + @Expose() @IsISO8601() @ApiProperty({ diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts index c915a04..8746158 100644 --- a/src/i18n/validators/index.ts +++ b/src/i18n/validators/index.ts @@ -23,6 +23,7 @@ export { validate, registerDecorator, ValidationArguments, + ValidateNested, } from "class-validator"; export { ValidationOptions }; From 64814e9017cda67825124b3fdf4f9af2602464c3 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 15 Jan 2025 19:30:56 +0200 Subject: [PATCH 034/180] feat: alt text for dish image files --- src/@base/drizzle/schema/many-to-many.ts | 3 ++- src/dishes/dishes.service.ts | 2 ++ src/dishes/entities/dish-image.entity.ts | 10 +++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/@base/drizzle/schema/many-to-many.ts b/src/@base/drizzle/schema/many-to-many.ts index 5ba30b0..d4ee4c1 100644 --- a/src/@base/drizzle/schema/many-to-many.ts +++ b/src/@base/drizzle/schema/many-to-many.ts @@ -2,7 +2,7 @@ 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, uuid } from "drizzle-orm/pg-core"; +import { integer, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core"; // ----------------------------------- // // Dishes to dish categories relation // @@ -44,6 +44,7 @@ export const dishesToImages = pgTable( { dishId: uuid("dishId").notNull(), imageFileId: uuid("imageFileId").notNull(), + alt: text("alt").notNull().default(""), sortIndex: integer("sortIndex").notNull().default(0), }, (t) => [ diff --git a/src/dishes/dishes.service.ts b/src/dishes/dishes.service.ts index ea242b2..06181c3 100644 --- a/src/dishes/dishes.service.ts +++ b/src/dishes/dishes.service.ts @@ -76,6 +76,7 @@ export class DishesService { ...dish, images: dish.dishesToImages.map((di) => ({ ...di.imageFile, + alt: di.alt, sortIndex: di.sortIndex, })), })); @@ -135,6 +136,7 @@ export class DishesService { ...result, images: result.dishesToImages.map((di) => ({ ...di.imageFile, + alt: di.alt, sortIndex: di.sortIndex, })), }; diff --git a/src/dishes/entities/dish-image.entity.ts b/src/dishes/entities/dish-image.entity.ts index 41b190a..da5e9bb 100644 --- a/src/dishes/entities/dish-image.entity.ts +++ b/src/dishes/entities/dish-image.entity.ts @@ -1,9 +1,17 @@ -import { IsNumber } from "@i18n-class-validator"; +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({ From c3c5b9bb5c5b574ac054217a75c0f5ea11e9ce5d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 15 Jan 2025 20:29:05 +0200 Subject: [PATCH 035/180] refactor: move dish methods to base @ folder --- src/dishes/{ => @}/dishes.controller.ts | 0 src/dishes/{ => @}/dishes.service.ts | 0 src/dishes/{ => @}/dtos/create-dish.dto.ts | 0 src/dishes/{ => @}/dtos/update-dish.dto.ts | 0 src/dishes/{ => @}/entities/dish-image.entity.ts | 0 src/dishes/{ => @}/entities/dish.entity.ts | 2 +- src/dishes/{ => @}/entities/dishes-paginated.entity.ts | 0 src/dishes/dishes.module.ts | 4 ++-- 8 files changed, 3 insertions(+), 3 deletions(-) rename src/dishes/{ => @}/dishes.controller.ts (100%) rename src/dishes/{ => @}/dishes.service.ts (100%) rename src/dishes/{ => @}/dtos/create-dish.dto.ts (100%) rename src/dishes/{ => @}/dtos/update-dish.dto.ts (100%) rename src/dishes/{ => @}/entities/dish-image.entity.ts (100%) rename src/dishes/{ => @}/entities/dish.entity.ts (97%) rename src/dishes/{ => @}/entities/dishes-paginated.entity.ts (100%) diff --git a/src/dishes/dishes.controller.ts b/src/dishes/@/dishes.controller.ts similarity index 100% rename from src/dishes/dishes.controller.ts rename to src/dishes/@/dishes.controller.ts diff --git a/src/dishes/dishes.service.ts b/src/dishes/@/dishes.service.ts similarity index 100% rename from src/dishes/dishes.service.ts rename to src/dishes/@/dishes.service.ts diff --git a/src/dishes/dtos/create-dish.dto.ts b/src/dishes/@/dtos/create-dish.dto.ts similarity index 100% rename from src/dishes/dtos/create-dish.dto.ts rename to src/dishes/@/dtos/create-dish.dto.ts diff --git a/src/dishes/dtos/update-dish.dto.ts b/src/dishes/@/dtos/update-dish.dto.ts similarity index 100% rename from src/dishes/dtos/update-dish.dto.ts rename to src/dishes/@/dtos/update-dish.dto.ts diff --git a/src/dishes/entities/dish-image.entity.ts b/src/dishes/@/entities/dish-image.entity.ts similarity index 100% rename from src/dishes/entities/dish-image.entity.ts rename to src/dishes/@/entities/dish-image.entity.ts diff --git a/src/dishes/entities/dish.entity.ts b/src/dishes/@/entities/dish.entity.ts similarity index 97% rename from src/dishes/entities/dish.entity.ts rename to src/dishes/@/entities/dish.entity.ts index 12f7826..debb78c 100644 --- a/src/dishes/entities/dish.entity.ts +++ b/src/dishes/@/entities/dish.entity.ts @@ -10,7 +10,7 @@ import { import { ApiProperty } 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"; +import { DishImageEntity } from "src/dishes/@/entities/dish-image.entity"; export class DishEntity implements IDish { @Expose() diff --git a/src/dishes/entities/dishes-paginated.entity.ts b/src/dishes/@/entities/dishes-paginated.entity.ts similarity index 100% rename from src/dishes/entities/dishes-paginated.entity.ts rename to src/dishes/@/entities/dishes-paginated.entity.ts diff --git a/src/dishes/dishes.module.ts b/src/dishes/dishes.module.ts index c227d80..7ed04d6 100644 --- a/src/dishes/dishes.module.ts +++ b/src/dishes/dishes.module.ts @@ -1,8 +1,8 @@ import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.module"; -import { DishesController } from "./dishes.controller"; -import { DishesService } from "./dishes.service"; +import { DishesController } from "./@/dishes.controller"; +import { DishesService } from "./@/dishes.service"; @Module({ imports: [DrizzleModule], From 1ecc37c7a16e3813a667fd79d233b11f12586a96 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 15 Jan 2025 20:45:00 +0200 Subject: [PATCH 036/180] feat: upload endpoint for dish images --- src/dishes/dishes.module.ts | 12 ++-- src/dishes/images/dish-images.controller.ts | 46 +++++++++++++++ src/dishes/images/dish-images.service.ts | 57 +++++++++++++++++++ .../images/dto/upload-dish-image.dto.ts | 31 ++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 src/dishes/images/dish-images.controller.ts create mode 100644 src/dishes/images/dish-images.service.ts create mode 100644 src/dishes/images/dto/upload-dish-image.dto.ts diff --git a/src/dishes/dishes.module.ts b/src/dishes/dishes.module.ts index 7ed04d6..6fabbe8 100644 --- a/src/dishes/dishes.module.ts +++ b/src/dishes/dishes.module.ts @@ -1,13 +1,17 @@ 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"; @Module({ - imports: [DrizzleModule], - controllers: [DishesController], - providers: [DishesService], - exports: [DishesService], + imports: [DrizzleModule, FilesModule, NestjsFormDataModule], + controllers: [DishesController, DishImagesController], + providers: [DishesService, DishImagesService], + exports: [DishesService, DishImagesService], }) 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..0c0c992 --- /dev/null +++ b/src/dishes/images/dish-images.controller.ts @@ -0,0 +1,46 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { Body, 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 { DishImageEntity } from "../@/entities/dish-image.entity"; + +import { DishImagesService } from "./dish-images.service"; +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) {} + + @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, + }); + } +} diff --git a/src/dishes/images/dish-images.service.ts b/src/dishes/images/dish-images.service.ts new file mode 100644 index 0000000..e914633 --- /dev/null +++ b/src/dishes/images/dish-images.service.ts @@ -0,0 +1,57 @@ +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; + + // 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, + }; + } +} 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; +} From 6f9bfaa603683da4612b6ad47b86985d1d0f8330 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 15 Jan 2025 20:56:27 +0200 Subject: [PATCH 037/180] feat: editing and deletion of images --- src/dishes/images/dish-images.controller.ts | 32 +++++++++- src/dishes/images/dish-images.service.ts | 64 +++++++++++++++++++ .../images/dto/update-dish-image.dto.ts | 13 ++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/dishes/images/dto/update-dish-image.dto.ts diff --git a/src/dishes/images/dish-images.controller.ts b/src/dishes/images/dish-images.controller.ts index 0c0c992..ab7cbb3 100644 --- a/src/dishes/images/dish-images.controller.ts +++ b/src/dishes/images/dish-images.controller.ts @@ -1,7 +1,7 @@ import { Controller } from "@core/decorators/controller.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; import { Worker } from "@core/decorators/worker.decorator"; -import { Body, Param, Post } from "@nestjs/common"; +import { Body, Delete, Param, Post, Put } from "@nestjs/common"; import { ApiConsumes, ApiForbiddenResponse, @@ -15,6 +15,7 @@ import { FormDataRequest } from "nestjs-form-data"; 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", { @@ -43,4 +44,33 @@ export class DishImagesController { alt: dto.alt, }); } + + @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, + }); + } + + @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 index e914633..7eec8aa 100644 --- a/src/dishes/images/dish-images.service.ts +++ b/src/dishes/images/dish-images.service.ts @@ -1,3 +1,5 @@ +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"; @@ -54,4 +56,66 @@ export class DishImagesService { sortIndex, }; } + + async updateImage( + dishId: string, + imageId: string, + data: { + alt: string; + }, + ): Promise { + // Update the dish-to-image relation + const [updated] = await this.pg + .update(schema.dishesToImages) + .set({ + alt: data.alt, + }) + .where( + eq(schema.dishesToImages.dishId, dishId) && + eq(schema.dishesToImages.imageFileId, imageId), + ) + .returning(); + + if (!updated) { + throw new BadRequestException("Image not found"); + } + + // 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..0261667 --- /dev/null +++ b/src/dishes/images/dto/update-dish-image.dto.ts @@ -0,0 +1,13 @@ +import { IsString } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +export class UpdateDishImageDto { + @ApiProperty({ + description: "Alternative text for the image", + example: "Delicious pasta dish with tomato sauce", + }) + @Expose() + @IsString() + alt: string; +} From 022d01ae4c066b09b6f980ba2b53e58cbb6fbbe8 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 15 Jan 2025 21:06:03 +0200 Subject: [PATCH 038/180] feat: sort index for updating --- src/dishes/@/dishes.service.ts | 24 ++++--- src/dishes/images/dish-images.controller.ts | 1 + src/dishes/images/dish-images.service.ts | 69 ++++++++++++++++--- .../images/dto/update-dish-image.dto.ts | 18 +++-- 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/src/dishes/@/dishes.service.ts b/src/dishes/@/dishes.service.ts index 06181c3..442cae9 100644 --- a/src/dishes/@/dishes.service.ts +++ b/src/dishes/@/dishes.service.ts @@ -74,11 +74,13 @@ export class DishesService { return result.map((dish) => ({ ...dish, - images: dish.dishesToImages.map((di) => ({ - ...di.imageFile, - alt: di.alt, - sortIndex: di.sortIndex, - })), + images: dish.dishesToImages + .sort((a, b) => a.sortIndex - b.sortIndex) + .map((di) => ({ + ...di.imageFile, + alt: di.alt, + sortIndex: di.sortIndex, + })), })); } @@ -134,11 +136,13 @@ export class DishesService { return { ...result, - images: result.dishesToImages.map((di) => ({ - ...di.imageFile, - alt: di.alt, - sortIndex: di.sortIndex, - })), + 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/images/dish-images.controller.ts b/src/dishes/images/dish-images.controller.ts index ab7cbb3..e2818f4 100644 --- a/src/dishes/images/dish-images.controller.ts +++ b/src/dishes/images/dish-images.controller.ts @@ -59,6 +59,7 @@ export class DishImagesController { ) { return this.dishImagesService.updateImage(dishId, imageId, { alt: dto.alt, + sortIndex: dto.sortIndex, }); } diff --git a/src/dishes/images/dish-images.service.ts b/src/dishes/images/dish-images.service.ts index 7eec8aa..902f047 100644 --- a/src/dishes/images/dish-images.service.ts +++ b/src/dishes/images/dish-images.service.ts @@ -34,7 +34,7 @@ export class DishImagesService { .from(schema.dishesToImages) .where(eq(schema.dishesToImages.dishId, dishId)); - const sortIndex = result[0].value; + const sortIndex = result[0].value + 1; // Upload the file first const uploadedFile = await this.filesService.uploadFile(file, { @@ -61,25 +61,74 @@ export class DishImagesService { dishId: string, imageId: string, data: { - alt: string; + alt?: string; + sortIndex?: number; }, ): Promise { - // Update the dish-to-image relation + // 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({ - alt: data.alt, - }) + .set(updateData) .where( eq(schema.dishesToImages.dishId, dishId) && eq(schema.dishesToImages.imageFileId, imageId), ) .returning(); - if (!updated) { - throw new BadRequestException("Image not found"); - } - // Get the file details const file = await this.pg.query.files.findFirst({ where: eq(schema.files.id, imageId), diff --git a/src/dishes/images/dto/update-dish-image.dto.ts b/src/dishes/images/dto/update-dish-image.dto.ts index 0261667..d5d7be1 100644 --- a/src/dishes/images/dto/update-dish-image.dto.ts +++ b/src/dishes/images/dto/update-dish-image.dto.ts @@ -1,13 +1,23 @@ -import { IsString } from "@i18n-class-validator"; -import { ApiProperty } from "@nestjs/swagger"; +import { IsNumber, IsOptional, IsString } from "@i18n-class-validator"; +import { ApiPropertyOptional } from "@nestjs/swagger"; import { Expose } from "class-transformer"; export class UpdateDishImageDto { - @ApiProperty({ + @ApiPropertyOptional({ description: "Alternative text for the image", example: "Delicious pasta dish with tomato sauce", }) @Expose() + @IsOptional() @IsString() - alt: string; + alt?: string; + + @ApiPropertyOptional({ + description: "Sort order index to swap with", + example: 2, + }) + @Expose() + @IsOptional() + @IsNumber() + sortIndex?: number; } From d99c5748080292f1866f2002f7456b057c8aa629 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 10:46:41 +0200 Subject: [PATCH 039/180] feat: move admin init to the workers service --- src/main.ts | 20 -------------------- src/workers/workers.service.ts | 25 +++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/main.ts b/src/main.ts index 644b63d..74be3f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,30 +3,11 @@ import { configApp } from "@core/config/app"; import env from "@core/env"; import { NestFactory } from "@nestjs/core"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; -import { schema } from "@postgress-db/drizzle.module"; -import { workers } from "@postgress-db/schema/workers"; -import { hash } from "argon2"; -import { drizzle } from "drizzle-orm/node-postgres"; import { patchNestJsSwagger } from "nestjs-zod"; -import { Pool } from "pg"; import { AppModule } from "./app.module"; import { AUTH_COOKIES } from "./auth/auth.types"; -export const createUserIfDbEmpty = async () => { - const db = drizzle(new Pool({ connectionString: env.POSTGRESQL_URL }), { - schema, - }); - - if ((await db.query.workers.findMany()).length === 0) { - await db.insert(workers).values({ - login: "admin", - passwordHash: await hash(env.INITIAL_ADMIN_PASSWORD ?? "123456"), - role: schema.ZodWorkerRole.Enum.SYSTEM_ADMIN, - }); - } -}; - async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -58,6 +39,5 @@ async function bootstrap() { await app.listen(env.PORT); } -createUserIfDbEmpty(); patchNestJsSwagger(); bootstrap(); diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index a804fa5..2fbb18b 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -1,10 +1,11 @@ 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 { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; -import { Inject, Injectable } from "@nestjs/common"; +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"; @@ -17,11 +18,15 @@ 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, ) {} + 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"); @@ -237,4 +242,20 @@ export class WorkersService { } 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(), + }); + } + } } From 3414f53edde11c78786679d4db0b660699762b1a Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 12:03:35 +0200 Subject: [PATCH 040/180] feat: proper handling for validation errors with custom exception filter --- src/@core/config/app.ts | 9 ++-- src/@core/errors/filter.ts | 50 ----------------- .../{handleError.ts => handle-error.ts} | 0 src/@core/errors/http-exception-filter.ts | 54 +++++++++++++++++++ src/@core/pipes/no-omit-validation.pipe.ts | 2 +- src/@core/pipes/validation.pipe.ts | 2 +- src/app.module.ts | 7 +-- src/i18n/validators/index.ts | 1 + 8 files changed, 62 insertions(+), 63 deletions(-) delete mode 100644 src/@core/errors/filter.ts rename src/@core/errors/{handleError.ts => handle-error.ts} (100%) create mode 100644 src/@core/errors/http-exception-filter.ts diff --git a/src/@core/config/app.ts b/src/@core/config/app.ts index b2f7441..009147c 100644 --- a/src/@core/config/app.ts +++ b/src/@core/config/app.ts @@ -1,21 +1,20 @@ +import { HttpExceptionFilter } from "@core/errors/http-exception-filter"; import { INestApplication } from "@nestjs/common"; import * as cookieParser from "cookie-parser"; -import { I18nValidationExceptionFilter, I18nValidationPipe } from "nestjs-i18n"; +import { I18nValidationPipe } from "nestjs-i18n"; export const configApp = (app: INestApplication) => { // Parse cookies app.use(cookieParser()); app.useGlobalPipes( - // new ValidationPipe({ - // transform: true, // Transform is recomended configuration for avoind issues with arrays of files transformations - // }), new I18nValidationPipe({ transform: true, }), ); - app.useGlobalFilters(new I18nValidationExceptionFilter()); + // app.useGlobalFilters(new I18nValidationExceptionFilter()); + app.useGlobalFilters(new HttpExceptionFilter()); // Enable CORS app.enableCors({ diff --git a/src/@core/errors/filter.ts b/src/@core/errors/filter.ts deleted file mode 100644 index 09dd1ab..0000000 --- a/src/@core/errors/filter.ts +++ /dev/null @@ -1,50 +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(); - console.log(response, exception); - - httpAdapter.reply( - ctx.getResponse(), - { - statusCode, - // @ts-expect-error response is not defined - 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..7e8a074 --- /dev/null +++ b/src/@core/errors/http-exception-filter.ts @@ -0,0 +1,54 @@ +// import { ValidationError } from "@i18n-class-validator"; +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; + } + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + const statusCode = exception.getStatus(); + + const timestamp = new Date().getTime(); + const validationErrors = this.getValidationErrors(exception); + + response.status(statusCode).json({ + statusCode, + path: request.url, + timestamp, + message: exception.message, + ...(validationErrors ? { validationErrors } : {}), + }); + } +} diff --git a/src/@core/pipes/no-omit-validation.pipe.ts b/src/@core/pipes/no-omit-validation.pipe.ts index edf5ebf..50a5d9e 100644 --- a/src/@core/pipes/no-omit-validation.pipe.ts +++ b/src/@core/pipes/no-omit-validation.pipe.ts @@ -1,6 +1,6 @@ /* 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"; diff --git a/src/@core/pipes/validation.pipe.ts b/src/@core/pipes/validation.pipe.ts index 93cd83d..fce2c18 100644 --- a/src/@core/pipes/validation.pipe.ts +++ b/src/@core/pipes/validation.pipe.ts @@ -1,6 +1,6 @@ /* 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"; diff --git a/src/app.module.ts b/src/app.module.ts index ca76ae8..ed8d898 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,12 +1,11 @@ import * as path from "path"; import env from "@core/env"; -import { AllExceptionsFilter } from "@core/errors/filter"; import { RolesGuard } from "@core/guards/roles.guard"; import { RedisModule } from "@liaoliaots/nestjs-redis"; 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_PIPE } from "@nestjs/core"; import { MongooseModule } from "@nestjs/mongoose"; import { ThrottlerModule } from "@nestjs/throttler"; import { NestjsFormDataModule } from "nestjs-form-data"; @@ -80,10 +79,6 @@ import { WorkersModule } from "./workers/workers.module"; }), ], providers: [ - { - provide: APP_FILTER, - useClass: AllExceptionsFilter, - }, { provide: APP_PIPE, useClass: ZodValidationPipe, diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts index 8746158..b87e751 100644 --- a/src/i18n/validators/index.ts +++ b/src/i18n/validators/index.ts @@ -21,6 +21,7 @@ import { i18nValidationMessage } from "nestjs-i18n"; // eslint-disable-next-line no-restricted-imports export { validate, + ValidationError, registerDecorator, ValidationArguments, ValidateNested, From 940a7470095fcf2b444f8cf929258f081cfa4e18 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 13:34:45 +0200 Subject: [PATCH 041/180] feat: error filter and translations for custom errors --- src/@core/decorators/filter.decorator.ts | 38 +++++++----- src/@core/decorators/pagination.decorator.ts | 10 ++- src/@core/decorators/sorting.decorator.ts | 22 +++---- .../exceptions/bad-request.exception.ts | 7 ++- .../errors/exceptions/conflict.exception.ts | 7 ++- .../errors/exceptions/forbidden.exception.ts | 7 ++- src/@core/errors/exceptions/form.exception.ts | 9 +-- .../errors/exceptions/not-found.exception.ts | 7 ++- .../exceptions/server-error.exception.ts | 7 ++- .../exceptions/unauthorized.exception.ts | 7 ++- src/@core/errors/http-exception-filter.ts | 39 +++++++++++- src/@core/errors/index.types.ts | 15 ++--- src/@core/guards/roles.guard.ts | 7 +-- src/@core/pipes/validation.pipe.ts | 33 +++++----- src/auth/services/auth.service.ts | 4 +- .../dish-categories.service.ts | 6 +- src/dishes/@/dishes.controller.ts | 14 +++-- src/dishes/@/dishes.service.ts | 4 +- src/guests/guests.controller.ts | 14 +++-- src/guests/guests.service.ts | 15 +++-- src/i18n/messages/en/errors.json | 61 +++++++++++++++++++ src/i18n/messages/et/errors.json | 61 +++++++++++++++++++ src/i18n/messages/ru/errors.json | 61 +++++++++++++++++++ .../@/services/restaurants.service.ts | 18 ++++-- .../hours/restaurant-hours.service.ts | 14 ++++- .../workshops/restaurant-workshops.service.ts | 30 +++++++-- src/timezones/timezones.service.ts | 4 -- src/workers/workers.controller.ts | 55 +++++++++-------- src/workers/workers.service.ts | 19 ++++-- 29 files changed, 432 insertions(+), 163 deletions(-) create mode 100644 src/i18n/messages/en/errors.json create mode 100644 src/i18n/messages/et/errors.json create mode 100644 src/i18n/messages/ru/errors.json diff --git a/src/@core/decorators/filter.decorator.ts b/src/@core/decorators/filter.decorator.ts index b523aff..8a2750d 100644 --- a/src/@core/decorators/filter.decorator.ts +++ b/src/@core/decorators/filter.decorator.ts @@ -36,28 +36,37 @@ export const FilterParams = createParamDecorator( const filters = JSON.parse(rawFilters as string); if (!Array.isArray(filters)) { - throw new BadRequestException({ - title: "Invalid filters format", - description: "Filters should be an array", + 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({ - title: "Invalid filter format", - description: "Each filter must have field, value and 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({ - title: "Invalid filter condition", - description: `Condition must be one of: ${Object.values( - FilterCondition, - ).join(", ")}`, - }); + 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(", ")}`, + // }); } }); @@ -66,9 +75,8 @@ export const FilterParams = createParamDecorator( if (error instanceof BadRequestException) { throw error; } - throw new BadRequestException({ - title: "Invalid filters format", - description: "Could not parse filters JSON", + throw new BadRequestException("errors.common.invalid-filters-format", { + property: "filters", }); } }, diff --git a/src/@core/decorators/pagination.decorator.ts b/src/@core/decorators/pagination.decorator.ts index 0ded2db..d2ae4af 100644 --- a/src/@core/decorators/pagination.decorator.ts +++ b/src/@core/decorators/pagination.decorator.ts @@ -32,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/sorting.decorator.ts b/src/@core/decorators/sorting.decorator.ts index 9a2f934..2664f9b 100644 --- a/src/@core/decorators/sorting.decorator.ts +++ b/src/@core/decorators/sorting.decorator.ts @@ -33,35 +33,27 @@ 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", }); } 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/http-exception-filter.ts b/src/@core/errors/http-exception-filter.ts index 7e8a074..403b738 100644 --- a/src/@core/errors/http-exception-filter.ts +++ b/src/@core/errors/http-exception-filter.ts @@ -1,4 +1,5 @@ // import { ValidationError } from "@i18n-class-validator"; +import { ErrorInstance } from "@core/errors/index.types"; import { ArgumentsHost, Catch, @@ -34,6 +35,29 @@ export class HttpExceptionFilter implements ExceptionFilter { 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(); @@ -41,14 +65,23 @@ export class HttpExceptionFilter implements ExceptionFilter { const statusCode = exception.getStatus(); const timestamp = new Date().getTime(); - const validationErrors = this.getValidationErrors(exception); + 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, path: request.url, timestamp, - message: exception.message, - ...(validationErrors ? { validationErrors } : {}), + 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 index b0e2e0c..a78bbc8 100644 --- a/src/@core/guards/roles.guard.ts +++ b/src/@core/guards/roles.guard.ts @@ -16,10 +16,9 @@ export class RolesGuard implements CanActivate { ]); const notAllowed = () => { - throw new ForbiddenException({ - title: "Forbidden", - description: "You don't have permission to access this resource", - }); + throw new ForbiddenException( + "errors.common.forbidden-access-to-resource", + ); }; // If there is no roles, then allow access diff --git a/src/@core/pipes/validation.pipe.ts b/src/@core/pipes/validation.pipe.ts index fce2c18..e482146 100644 --- a/src/@core/pipes/validation.pipe.ts +++ b/src/@core/pipes/validation.pipe.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/ban-types */ -import { FormException } from "@core/errors/exceptions/form.exception"; import { handleError } from "@core/errors/handle-error"; import { validate } from "@i18n-class-validator"; import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common"; @@ -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/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index cd749e0..eed864d 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -24,12 +24,12 @@ 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; diff --git a/src/dish-categories/dish-categories.service.ts b/src/dish-categories/dish-categories.service.ts index 4e88b38..6208f27 100644 --- a/src/dish-categories/dish-categories.service.ts +++ b/src/dish-categories/dish-categories.service.ts @@ -86,7 +86,9 @@ export class DishCategoriesService { const category = categories[0]; if (!category) { - throw new ServerErrorException("Failed to create dish category"); + throw new ServerErrorException( + "errors.dish-categories.failed-to-create-dish-category", + ); } return category; @@ -98,7 +100,7 @@ export class DishCategoriesService { ): Promise { 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", ); } diff --git a/src/dishes/@/dishes.controller.ts b/src/dishes/@/dishes.controller.ts index 2087117..2957557 100644 --- a/src/dishes/@/dishes.controller.ts +++ b/src/dishes/@/dishes.controller.ts @@ -79,7 +79,7 @@ export class DishesController { const dish = await this.dishesService.create(data); if (!dish) { - throw new BadRequestException("Failed to create dish"); + throw new BadRequestException("errors.dishes.failed-to-create-dish"); } return dish; @@ -100,13 +100,15 @@ export class DishesController { }) 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 dish = await this.dishesService.findById(id); if (!dish) { - throw new NotFoundException("Dish with this id doesn't exist"); + throw new NotFoundException("errors.dishes.with-this-id-doesnt-exist"); } return dish; @@ -130,7 +132,9 @@ export class DishesController { @Body() data: UpdateDishDto, ): 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 updatedDish = await this.dishesService.update(id, { @@ -139,7 +143,7 @@ export class DishesController { }); if (!updatedDish) { - throw new NotFoundException("Dish with this id doesn't exist"); + 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 index 442cae9..2965f84 100644 --- a/src/dishes/@/dishes.service.ts +++ b/src/dishes/@/dishes.service.ts @@ -94,7 +94,7 @@ export class DishesService { const dish = dishes[0]; if (!dish) { - throw new ServerErrorException("Failed to create dish"); + throw new ServerErrorException("errors.dishes.failed-to-create-dish"); } return { ...dish, images: [] }; @@ -106,7 +106,7 @@ export class DishesService { ): Promise { 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", ); } diff --git a/src/guests/guests.controller.ts b/src/guests/guests.controller.ts index 81ce561..6ed796c 100644 --- a/src/guests/guests.controller.ts +++ b/src/guests/guests.controller.ts @@ -81,7 +81,7 @@ export class GuestsController { const guest = await this.guestsService.create(data); if (!guest) { - throw new BadRequestException("Failed to create guest"); + throw new BadRequestException("errors.guests.failed-to-create-guest"); } return guest; @@ -102,13 +102,15 @@ export class GuestsController { }) 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 guest = await this.guestsService.findById(id); if (!guest) { - throw new NotFoundException("Guest with this id doesn't exist"); + throw new NotFoundException("errors.guests.with-this-id-doesnt-exist"); } return guest; @@ -132,7 +134,9 @@ export class GuestsController { @Body() data: UpdateGuestDto, ): 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 updatedGuest = await this.guestsService.update(id, { @@ -141,7 +145,7 @@ export class GuestsController { }); if (!updatedGuest) { - throw new NotFoundException("Guest with this id doesn't exist"); + throw new NotFoundException("errors.guests.with-this-id-doesnt-exist"); } return updatedGuest; diff --git a/src/guests/guests.service.ts b/src/guests/guests.service.ts index 2c9ae40..3cfa3d2 100644 --- a/src/guests/guests.service.ts +++ b/src/guests/guests.service.ts @@ -67,12 +67,19 @@ export class GuestsService { try { const phoneNumber = parsePhoneNumber(phone); if (!phoneNumber || !isValidPhoneNumber(phone)) { - throw new BadRequestException("Invalid phone number"); + 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("Invalid phone number format"); + throw new BadRequestException( + "errors.common.invalid-phone-number-format", + { + property: "phone", + }, + ); } } @@ -91,7 +98,7 @@ export class GuestsService { const guest = guests[0]; if (!guest) { - throw new ServerErrorException("Failed to create guest"); + throw new ServerErrorException("errors.guests.failed-to-create-guest"); } return guest; @@ -103,7 +110,7 @@ export class GuestsService { ): Promise { 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", ); } diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json new file mode 100644 index 0000000..4cae149 --- /dev/null +++ b/src/i18n/messages/en/errors.json @@ -0,0 +1,61 @@ +{ + "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" + }, + "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" + }, + "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" + }, + "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" + } +} diff --git a/src/i18n/messages/et/errors.json b/src/i18n/messages/et/errors.json new file mode 100644 index 0000000..fe4cbba --- /dev/null +++ b/src/i18n/messages/et/errors.json @@ -0,0 +1,61 @@ +{ + "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" + } +} diff --git a/src/i18n/messages/ru/errors.json b/src/i18n/messages/ru/errors.json new file mode 100644 index 0000000..6287e0a --- /dev/null +++ b/src/i18n/messages/ru/errors.json @@ -0,0 +1,61 @@ +{ + "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": "Неверный формат фильтра: каждый фильтр должен иметь поле, значение и условие" + }, + "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 не существует" + } +} diff --git a/src/restaurants/@/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts index 6fb53d4..2ca74ba 100644 --- a/src/restaurants/@/services/restaurants.service.ts +++ b/src/restaurants/@/services/restaurants.service.ts @@ -68,9 +68,12 @@ export class RestaurantsService { */ public async create(dto: CreateRestaurantDto): Promise { if (dto.timezone && !this.timezonesService.checkTimezone(dto.timezone)) { - throw new BadRequestException({ - title: "Provided timezone can't be set", - }); + throw new BadRequestException( + "errors.restaurants.provided-timezone-cant-be-set", + { + property: "timezone", + }, + ); } const data = await this.pg @@ -94,9 +97,12 @@ export class RestaurantsService { dto: UpdateRestaurantDto, ): Promise { if (dto.timezone && !this.timezonesService.checkTimezone(dto.timezone)) { - throw new BadRequestException({ - title: "Provided timezone can't be set", - }); + throw new BadRequestException( + "errors.restaurants.provided-timezone-cant-be-set", + { + property: "timezone", + }, + ); } // Disable restaurant if it is closed forever diff --git a/src/restaurants/hours/restaurant-hours.service.ts b/src/restaurants/hours/restaurant-hours.service.ts index d3f8df5..5de287e 100644 --- a/src/restaurants/hours/restaurant-hours.service.ts +++ b/src/restaurants/hours/restaurant-hours.service.ts @@ -41,7 +41,10 @@ export class RestaurantHoursService { ): 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", + }, ); } @@ -72,7 +75,10 @@ export class RestaurantHoursService { ): 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", + }, ); } @@ -128,7 +134,9 @@ 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", + ); } const result = await this.pg diff --git a/src/restaurants/workshops/restaurant-workshops.service.ts b/src/restaurants/workshops/restaurant-workshops.service.ts index 1878d0f..7703b72 100644 --- a/src/restaurants/workshops/restaurant-workshops.service.ts +++ b/src/restaurants/workshops/restaurant-workshops.service.ts @@ -47,7 +47,10 @@ export class RestaurantWorkshopsService { ): 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", + }, ); } @@ -77,7 +80,10 @@ export class RestaurantWorkshopsService { ): 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", + }, ); } @@ -101,7 +107,10 @@ export class RestaurantWorkshopsService { ): Promise { if (!(await this.isExists(id))) { throw new BadRequestException( - `Restaurant workshop with id ${id} not found`, + "errors.restaurant-workshops.with-this-id-doesnt-exist", + { + property: "id", + }, ); } @@ -125,7 +134,10 @@ export class RestaurantWorkshopsService { ): Promise<{ id: string }> { if (!(await this.isExists(id, restaurantId))) { throw new BadRequestException( - `Restaurant workshop with id ${id} not found`, + "errors.restaurant-workshops.with-this-id-doesnt-exist", + { + property: "id", + }, ); } @@ -145,7 +157,10 @@ export class RestaurantWorkshopsService { public async getWorkers(workshopId: string): Promise { if (!(await this.isExists(workshopId))) { throw new BadRequestException( - `Restaurant workshop with id ${workshopId} not found`, + "errors.restaurant-workshops.with-this-id-doesnt-exist", + { + property: "workshopId", + }, ); } @@ -175,7 +190,10 @@ export class RestaurantWorkshopsService { ): Promise { if (!(await this.isExists(workshopId))) { throw new BadRequestException( - `Restaurant workshop with id ${workshopId} not found`, + "errors.restaurant-workshops.with-this-id-doesnt-exist", + { + property: "workshopId", + }, ); } diff --git a/src/timezones/timezones.service.ts b/src/timezones/timezones.service.ts index a42a5f2..e35d877 100644 --- a/src/timezones/timezones.service.ts +++ b/src/timezones/timezones.service.ts @@ -1,13 +1,9 @@ import { Injectable } from "@nestjs/common"; import { getTimeZones } from "@vvo/tzdb"; -// import { formatISO } from "date-fns"; -// import { utcToZonedTime } from "date-fns-tz"; @Injectable() export class TimezonesService { getAllTimezones(): string[] { - // return Intl.supportedValuesOf("timeZone"); - const timezones = getTimeZones({ includeUtc: true, }).filter((tz) => tz.continentCode === "EU" || tz.countryCode === "RU"); diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index 1328e0d..db5869c 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -9,7 +9,6 @@ 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"; @@ -97,24 +96,18 @@ export class WorkersController { 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); @@ -135,13 +128,15 @@ 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; @@ -168,7 +163,9 @@ 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; @@ -178,17 +175,21 @@ export class WorkersController { 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", + }, + ); } } @@ -198,7 +199,7 @@ export class WorkersController { }); if (!updatedWorker) { - throw new NotFoundException("Worker with this id doesn't exist"); + 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 2fbb18b..1862c41 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -29,7 +29,12 @@ export class WorkersService implements OnApplicationBootstrap { 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; @@ -180,8 +185,9 @@ export class WorkersService implements OnApplicationBootstrap { .returning(); const worker = workers[0]; + if (!worker || !worker.login) { - throw new ServerErrorException("Failed to create worker"); + throw new ServerErrorException("errors.workers.failed-to-create-worker"); } return await this.findOneByLogin(worker.login); @@ -207,7 +213,12 @@ export class WorkersService implements OnApplicationBootstrap { exist.id !== id && exist.login.toLowerCase() === login.toLowerCase() ) { - throw new ConflictException("Worker with this login already exists"); + throw new ConflictException( + "errors.workers.worker-with-this-login-already-exists", + { + property: "login", + }, + ); } } @@ -217,7 +228,7 @@ export class WorkersService implements OnApplicationBootstrap { 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", ); } From ee5d205dd07bce23d31f432f33bacfdecbb39cbd Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 13:51:24 +0200 Subject: [PATCH 042/180] feat: header for disable refresh session for frontend --- src/auth/guards/session-auth.guard.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/auth/guards/session-auth.guard.ts b/src/auth/guards/session-auth.guard.ts index 43891b5..2f21f9d 100644 --- a/src/auth/guards/session-auth.guard.ts +++ b/src/auth/guards/session-auth.guard.ts @@ -43,6 +43,10 @@ export class SessionAuthGuard implements CanActivate { return isPublic; } + private async _isRefreshDisabled(req: Request) { + return !!req.headers["x-disable-session-refresh"]; + } + private async _handleSession(req: Request, res: Response) { res; const jwtSign = this.getCookie(req, AUTH_COOKIES.token); @@ -62,10 +66,11 @@ export class SessionAuthGuard implements CanActivate { req.session = session; req.worker = session?.worker ?? null; + const isRefreshDisabled = await this._isRefreshDisabled(req); const isRequireRefresh = this.sessionsService.isSessionRequireRefresh(session); - if (isRequireRefresh) { + if (isRequireRefresh && !isRefreshDisabled) { const newSignedJWT = await this.sessionsService.refreshSignedSession( jwtSign, { From 2fed35e89805cfd494333c1f46994a7c1511cd21 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 14:06:06 +0200 Subject: [PATCH 043/180] feat: dishes to workshops model --- src/@base/drizzle/schema/dishes.ts | 32 +++++++++++++++++++ .../drizzle/schema/restaurant-workshop.ts | 9 ++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts index c3514eb..30b0350 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -2,12 +2,14 @@ import { dishesToCategories, dishesToImages, } from "@postgress-db/schema/many-to-many"; +import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; import { relations } from "drizzle-orm"; import { boolean, integer, pgEnum, pgTable, + primaryKey, text, timestamp, uuid, @@ -59,7 +61,37 @@ export const dishes = pgTable("dishes", { export type IDish = typeof dishes.$inferSelect; +export const dishesToWorkshops = pgTable( + "dishesToWorkshops", + { + dishId: uuid("dishId").notNull(), + workshopId: uuid("workshopId").notNull(), + price: integer("price").notNull().default(0), + isInStoplist: boolean("isInStoplist").notNull().default(true), + }, + (t) => [ + primaryKey({ + columns: [t.dishId, t.workshopId], + }), + ], +); + +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, ({ many }) => ({ dishesToCategories: many(dishesToCategories), dishesToImages: many(dishesToImages), + dishesToWorkshops: many(dishesToWorkshops), })); diff --git a/src/@base/drizzle/schema/restaurant-workshop.ts b/src/@base/drizzle/schema/restaurant-workshop.ts index 1d970fe..5cd4e8e 100644 --- a/src/@base/drizzle/schema/restaurant-workshop.ts +++ b/src/@base/drizzle/schema/restaurant-workshop.ts @@ -1,3 +1,4 @@ +import { dishesToWorkshops } from "@postgress-db/schema/dishes"; import { relations } from "drizzle-orm"; import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; @@ -27,6 +28,8 @@ export const restaurantWorkshops = pgTable("restaurantWorkshop", { updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); +export type IRestaurantWorkshop = typeof restaurantWorkshops.$inferSelect; + export const workshopWorkers = pgTable("workshopWorkers", { workerId: uuid("workerId") .notNull() @@ -37,6 +40,8 @@ export const workshopWorkers = pgTable("workshopWorkers", { createdAt: timestamp("createdAt").notNull().defaultNow(), }); +export type IWorkshopWorker = typeof workshopWorkers.$inferSelect; + export const restaurantWorkshopRelations = relations( restaurantWorkshops, ({ one, many }) => ({ @@ -45,6 +50,7 @@ export const restaurantWorkshopRelations = relations( references: [restaurants.id], }), workers: many(workshopWorkers), + dishesToWorkshops: many(dishesToWorkshops), }), ); @@ -61,6 +67,3 @@ export const workshopWorkerRelations = relations( }), }), ); - -export type IRestaurantWorkshop = typeof restaurantWorkshops.$inferSelect; -export type IWorkshopWorker = typeof workshopWorkers.$inferSelect; From cb9156e67059b078e9295465ff0dae7b7ff47d95 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 14:38:54 +0200 Subject: [PATCH 044/180] feat: dish pricelist endpoint --- src/@base/drizzle/drizzle.module.ts | 2 + src/@base/drizzle/schema/dishes.ts | 5 + src/@base/drizzle/schema/general.ts | 4 + src/dishes/dishes.module.ts | 12 +- .../pricelist/dish-pricelist.controller.ts | 26 ++++ .../pricelist/dish-pricelist.service.ts | 55 ++++++++ .../entities/dish-pricelist-item.entity.ts | 124 ++++++++++++++++++ 7 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 src/dishes/pricelist/dish-pricelist.controller.ts create mode 100644 src/dishes/pricelist/dish-pricelist.service.ts create mode 100644 src/dishes/pricelist/entities/dish-pricelist-item.entity.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index 21f5cf9..be27f51 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -9,6 +9,7 @@ import { PG_CONNECTION } from "../../constants"; import * as dishCategories from "./schema/dish-categories"; import * as dishes from "./schema/dishes"; 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 restaurantWorkshops from "./schema/restaurant-workshop"; @@ -17,6 +18,7 @@ import * as sessions from "./schema/sessions"; import * as workers from "./schema/workers"; export const schema = { + ...general, ...restaurants, ...sessions, ...workers, diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts index 30b0350..b6acf49 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -1,3 +1,4 @@ +import { currencyEnum } from "@postgress-db/schema/general"; import { dishesToCategories, dishesToImages, @@ -67,7 +68,11 @@ export const dishesToWorkshops = pgTable( dishId: uuid("dishId").notNull(), workshopId: uuid("workshopId").notNull(), price: integer("price").notNull().default(0), + currency: currencyEnum("currency").notNull().default("EUR"), isInStoplist: boolean("isInStoplist").notNull().default(true), + isActive: boolean("isActive").notNull().default(true), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), }, (t) => [ primaryKey({ diff --git a/src/@base/drizzle/schema/general.ts b/src/@base/drizzle/schema/general.ts index 5b7102a..eb742a9 100644 --- a/src/@base/drizzle/schema/general.ts +++ b/src/@base/drizzle/schema/general.ts @@ -9,3 +9,7 @@ export const dayOfWeekEnum = pgEnum("day_of_week", [ "saturday", "sunday", ]); + +export const currencyEnum = pgEnum("currency", ["EUR", "USD", "RUB"]); + +export type ICurrency = (typeof currencyEnum.enumValues)[number]; diff --git a/src/dishes/dishes.module.ts b/src/dishes/dishes.module.ts index 6fabbe8..e0f6726 100644 --- a/src/dishes/dishes.module.ts +++ b/src/dishes/dishes.module.ts @@ -7,11 +7,17 @@ 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], - providers: [DishesService, DishImagesService], - exports: [DishesService, DishImagesService], + controllers: [ + DishesController, + DishImagesController, + DishPricelistController, + ], + providers: [DishesService, DishImagesService, DishPricelistService], + exports: [DishesService, DishImagesService, DishPricelistService], }) export class DishesModule {} diff --git a/src/dishes/pricelist/dish-pricelist.controller.ts b/src/dishes/pricelist/dish-pricelist.controller.ts new file mode 100644 index 0000000..c6c3a0f --- /dev/null +++ b/src/dishes/pricelist/dish-pricelist.controller.ts @@ -0,0 +1,26 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Get, Param } from "@nestjs/common"; +import { ApiOperation, ApiResponse } from "@nestjs/swagger"; + +import { DishPricelistService } from "./dish-pricelist.service"; +import DishPricelistItemEntity from "./entities/dish-pricelist-item.entity"; + +@Controller("dishes/:id/pricelist", { + tags: ["dishes"], +}) +export class DishPricelistController { + constructor(private readonly dishPricelistService: DishPricelistService) {} + + @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); + } +} diff --git a/src/dishes/pricelist/dish-pricelist.service.ts b/src/dishes/pricelist/dish-pricelist.service.ts new file mode 100644 index 0000000..0ef4103 --- /dev/null +++ b/src/dishes/pricelist/dish-pricelist.service.ts @@ -0,0 +1,55 @@ +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 { PG_CONNECTION } from "src/constants"; + +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 { + // Get all restaurants with their workshops and dish relationships + const restaurants = await this.pg.query.restaurants.findMany({ + with: { + workshops: { + with: { + dishesToWorkshops: { + where: eq(schema.dishesToWorkshops.dishId, dishId), + }, + }, + }, + }, + }); + + // Transform the data into the required format + return restaurants.map((restaurant): IDishPricelistItem => { + // Find first dish-workshop relationship to get price info + const firstDishWorkshop = restaurant.workshops + .flatMap((w) => w.dishesToWorkshops) + .find((dw) => dw); + + 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?.isActive ?? false, + createdAt: dishWorkshop?.createdAt ?? null, + updatedAt: dishWorkshop?.updatedAt ?? null, + }; + }), + price: firstDishWorkshop?.price ?? 0, + currency: firstDishWorkshop?.currency ?? "EUR", + isInStoplist: firstDishWorkshop?.isInStoplist ?? false, + }; + }); + } +} 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..42754fb --- /dev/null +++ b/src/dishes/pricelist/entities/dish-pricelist-item.entity.ts @@ -0,0 +1,124 @@ +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; + updatedAt: Date | null; +} + +export interface IDishPricelistItem { + restaurantId: string; + restaurantName: string; + workshops: IDishPricelistWorkshop[]; + price: number; + currency: ICurrency; + isInStoplist: boolean; +} + +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; + + @Expose() + @ApiProperty({ + description: "When the workshop assignment was last updated", + example: "2024-03-20T12:00:00Z", + nullable: true, + }) + updatedAt: 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; +} From f4b89644a2529b03a8b13e5b9400706b5be0c09f Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 16:52:10 +0200 Subject: [PATCH 045/180] refactor: individual relation for dish to restaurants and dish to workshops --- src/@base/drizzle/schema/dishes.ts | 43 ++++++++- src/@base/drizzle/schema/restaurants.ts | 2 + .../pricelist/dish-pricelist.controller.ts | 19 +++- .../pricelist/dish-pricelist.service.ts | 94 ++++++++++++++++--- .../dto/update-dish-pricelist.dto.ts | 47 ++++++++++ .../entities/dish-pricelist-item.entity.ts | 27 ++++-- 6 files changed, 206 insertions(+), 26 deletions(-) create mode 100644 src/dishes/pricelist/dto/update-dish-pricelist.dto.ts diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts index b6acf49..741a2d5 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -4,6 +4,7 @@ import { dishesToImages, } from "@postgress-db/schema/many-to-many"; import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; +import { restaurants } from "@postgress-db/schema/restaurants"; import { relations } from "drizzle-orm"; import { boolean, @@ -62,18 +63,47 @@ export const dishes = pgTable("dishes", { export type IDish = typeof dishes.$inferSelect; -export const dishesToWorkshops = pgTable( - "dishesToWorkshops", +export const dishesToRestaurants = pgTable( + "dishesToRestaurants", { dishId: uuid("dishId").notNull(), - workshopId: uuid("workshopId").notNull(), + restaurantId: uuid("restaurantId").notNull(), price: integer("price").notNull().default(0), currency: currencyEnum("currency").notNull().default("EUR"), - isInStoplist: boolean("isInStoplist").notNull().default(true), - isActive: boolean("isActive").notNull().default(true), + isInStopList: boolean("isInStopList").notNull().default(false), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").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( + "dishesToWorkshops", + { + dishId: uuid("dishId").notNull(), + workshopId: uuid("workshopId").notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, (t) => [ primaryKey({ columns: [t.dishId, t.workshopId], @@ -81,6 +111,8 @@ export const dishesToWorkshops = pgTable( ], ); +export type IDishToWorkshop = typeof dishesToWorkshops.$inferSelect; + export const dishesToWorkshopsRelations = relations( dishesToWorkshops, ({ one }) => ({ @@ -99,4 +131,5 @@ export const dishRelations = relations(dishes, ({ many }) => ({ dishesToCategories: many(dishesToCategories), dishesToImages: many(dishesToImages), dishesToWorkshops: many(dishesToWorkshops), + dishesToRestaurants: many(dishesToRestaurants), })); diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 7b43cb1..c89d535 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -1,3 +1,4 @@ +import { dishesToRestaurants } from "@postgress-db/schema/dishes"; import { orders } from "@postgress-db/schema/orders"; import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; import { relations } from "drizzle-orm"; @@ -68,6 +69,7 @@ export const restaurantRelations = relations(restaurants, ({ many }) => ({ workers: many(workers), workshops: many(restaurantWorkshops), orders: many(orders), + dishesToRestaurants: many(dishesToRestaurants), })); export const restaurantHourRelations = relations( diff --git a/src/dishes/pricelist/dish-pricelist.controller.ts b/src/dishes/pricelist/dish-pricelist.controller.ts index c6c3a0f..c2a28eb 100644 --- a/src/dishes/pricelist/dish-pricelist.controller.ts +++ b/src/dishes/pricelist/dish-pricelist.controller.ts @@ -1,9 +1,10 @@ import { Controller } from "@core/decorators/controller.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; -import { Get, Param } from "@nestjs/common"; +import { Body, Get, Param, Put } from "@nestjs/common"; import { ApiOperation, ApiResponse } from "@nestjs/swagger"; 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", { @@ -23,4 +24,20 @@ export class DishPricelistController { async getPricelist(@Param("id") dishId: string) { return this.dishPricelistService.getPricelist(dishId); } + + @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, + ) { + 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 index 0ef4103..ababfa6 100644 --- a/src/dishes/pricelist/dish-pricelist.service.ts +++ b/src/dishes/pricelist/dish-pricelist.service.ts @@ -1,9 +1,10 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { BadRequestException, Inject, Injectable } from "@nestjs/common"; import { schema } from "@postgress-db/drizzle.module"; -import { eq } from "drizzle-orm"; +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() @@ -23,15 +24,15 @@ export class DishPricelistService { }, }, }, + dishesToRestaurants: { + where: eq(schema.dishesToRestaurants.dishId, dishId), + }, }, }); // Transform the data into the required format return restaurants.map((restaurant): IDishPricelistItem => { - // Find first dish-workshop relationship to get price info - const firstDishWorkshop = restaurant.workshops - .flatMap((w) => w.dishesToWorkshops) - .find((dw) => dw); + const dishToRestaurant = restaurant.dishesToRestaurants[0]; return { restaurantId: restaurant.id, @@ -41,15 +42,86 @@ export class DishPricelistService { return { workshopId: workshop.id, workshopName: workshop.name, - isActive: dishWorkshop?.isActive ?? false, + isActive: !!dishWorkshop, createdAt: dishWorkshop?.createdAt ?? null, - updatedAt: dishWorkshop?.updatedAt ?? null, }; }), - price: firstDishWorkshop?.price ?? 0, - currency: firstDishWorkshop?.currency ?? "EUR", - isInStoplist: firstDishWorkshop?.isInStoplist ?? false, + price: 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 { + // 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( + `Workshop IDs ${invalidWorkshopIds.join(", ")} do not belong to restaurant ${restaurantId}`, + ); + } + + // Update or create dish-restaurant relation + await this.pg + .insert(schema.dishesToRestaurants) + .values({ + dishId, + restaurantId, + price: dto.price, + currency: dto.currency, + isInStopList: dto.isInStoplist, + }) + .onConflictDoUpdate({ + target: [ + schema.dishesToRestaurants.dishId, + schema.dishesToRestaurants.restaurantId, + ], + set: { + price: dto.price, + currency: dto.currency, + isInStopList: dto.isInStoplist, + updatedAt: new Date(), + }, + }); + + // Delete all existing workshop relations for this dish in this restaurant + await this.pg.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 this.pg.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 index 42754fb..5e63b2c 100644 --- a/src/dishes/pricelist/entities/dish-pricelist-item.entity.ts +++ b/src/dishes/pricelist/entities/dish-pricelist-item.entity.ts @@ -16,7 +16,6 @@ export interface IDishPricelistWorkshop { workshopName: string; isActive: boolean; createdAt: Date | null; - updatedAt: Date | null; } export interface IDishPricelistItem { @@ -26,6 +25,8 @@ export interface IDishPricelistItem { price: number; currency: ICurrency; isInStoplist: boolean; + createdAt: Date | null; + updatedAt: Date | null; } export class DishPricelistWorkshopEntity implements IDishPricelistWorkshop { @@ -60,14 +61,6 @@ export class DishPricelistWorkshopEntity implements IDishPricelistWorkshop { nullable: true, }) createdAt: Date | null; - - @Expose() - @ApiProperty({ - description: "When the workshop assignment was last updated", - example: "2024-03-20T12:00:00Z", - nullable: true, - }) - updatedAt: Date | null; } export default class DishPricelistItemEntity implements IDishPricelistItem { @@ -121,4 +114,20 @@ export default class DishPricelistItemEntity implements IDishPricelistItem { 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; } From 7657bd07d644cecea0e5decab5981e614c105c21 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 17:29:54 +0200 Subject: [PATCH 046/180] feat: allow decimals for euro and usd prices --- src/@base/drizzle/schema/dishes.ts | 3 ++- src/dishes/pricelist/dish-pricelist.service.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts index 741a2d5..1ff9494 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -8,6 +8,7 @@ import { restaurants } from "@postgress-db/schema/restaurants"; import { relations } from "drizzle-orm"; import { boolean, + decimal, integer, pgEnum, pgTable, @@ -68,7 +69,7 @@ export const dishesToRestaurants = pgTable( { dishId: uuid("dishId").notNull(), restaurantId: uuid("restaurantId").notNull(), - price: integer("price").notNull().default(0), + price: decimal("price", { precision: 10, scale: 2 }).notNull().default("0"), currency: currencyEnum("currency").notNull().default("EUR"), isInStopList: boolean("isInStopList").notNull().default(false), createdAt: timestamp("createdAt").notNull().defaultNow(), diff --git a/src/dishes/pricelist/dish-pricelist.service.ts b/src/dishes/pricelist/dish-pricelist.service.ts index ababfa6..62dfcfe 100644 --- a/src/dishes/pricelist/dish-pricelist.service.ts +++ b/src/dishes/pricelist/dish-pricelist.service.ts @@ -46,7 +46,7 @@ export class DishPricelistService { createdAt: dishWorkshop?.createdAt ?? null, }; }), - price: dishToRestaurant?.price ?? 0, + price: parseFloat(dishToRestaurant?.price ?? "0"), currency: dishToRestaurant?.currency ?? "EUR", isInStoplist: dishToRestaurant?.isInStopList ?? false, createdAt: dishToRestaurant?.createdAt ?? null, @@ -82,7 +82,7 @@ export class DishPricelistService { .values({ dishId, restaurantId, - price: dto.price, + price: dto.price.toString(), currency: dto.currency, isInStopList: dto.isInStoplist, }) @@ -92,7 +92,7 @@ export class DishPricelistService { schema.dishesToRestaurants.restaurantId, ], set: { - price: dto.price, + price: dto.price.toString(), currency: dto.currency, isInStopList: dto.isInStoplist, updatedAt: new Date(), From 63fbdff1b71405d439c58319157a3dae34fd2c96 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 16 Jan 2025 17:43:02 +0200 Subject: [PATCH 047/180] feat: search param for dishes --- src/@core/decorators/search.decorator.ts | 31 ++++++++++++++++++++++++ src/dishes/@/dishes.controller.ts | 21 +++++++++++++++- src/dishes/@/dishes.service.ts | 4 +-- src/i18n/messages/en/errors.json | 4 ++- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 src/@core/decorators/search.decorator.ts 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/dishes/@/dishes.controller.ts b/src/dishes/@/dishes.controller.ts index 2957557..190a2b4 100644 --- a/src/dishes/@/dishes.controller.ts +++ b/src/dishes/@/dishes.controller.ts @@ -1,9 +1,14 @@ import { Controller } from "@core/decorators/controller.decorator"; -import { FilterParams, IFilters } from "@core/decorators/filter.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 { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; @@ -54,8 +59,22 @@ export class DishesController { sorting: ISorting, @PaginationParams() pagination: IPagination, @FilterParams() filters?: IFilters, + @SearchParam() search?: 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, + }); + } + const total = await this.dishesService.getTotalCount(filters); + const data = await this.dishesService.findMany({ pagination, sorting, diff --git a/src/dishes/@/dishes.service.ts b/src/dishes/@/dishes.service.ts index 2965f84..442cae9 100644 --- a/src/dishes/@/dishes.service.ts +++ b/src/dishes/@/dishes.service.ts @@ -94,7 +94,7 @@ export class DishesService { const dish = dishes[0]; if (!dish) { - throw new ServerErrorException("errors.dishes.failed-to-create-dish"); + throw new ServerErrorException("Failed to create dish"); } return { ...dish, images: [] }; @@ -106,7 +106,7 @@ export class DishesService { ): Promise { if (Object.keys(dto).length === 0) { throw new BadRequestException( - "errors.common.atleast-one-field-should-be-provided", + "You should provide at least one field to update", ); } diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 4cae149..199a3df 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -20,7 +20,9 @@ "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-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" }, "auth": { "invalid-credentials": "Invalid credentials" From 92bd82494669f9c841b3e985a58425e76c8c6a55 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Sun, 19 Jan 2025 13:56:23 +0200 Subject: [PATCH 048/180] feat: orders drizzle model --- src/@base/drizzle/drizzle.module.ts | 4 + src/@base/drizzle/schema/dishes.ts | 2 + src/@base/drizzle/schema/guests.ts | 8 +- src/@base/drizzle/schema/order-deliveries.ts | 61 +++++++++++++++ src/@base/drizzle/schema/order-dishes.ts | 81 ++++++++++++++++++++ src/@base/drizzle/schema/orders.ts | 73 +++++++++++++++--- src/@base/drizzle/schema/workers.ts | 2 + 7 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 src/@base/drizzle/schema/order-deliveries.ts create mode 100644 src/@base/drizzle/schema/order-dishes.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index be27f51..19f8c59 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -12,6 +12,8 @@ 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 restaurantWorkshops from "./schema/restaurant-workshop"; import * as restaurants from "./schema/restaurants"; import * as sessions from "./schema/sessions"; @@ -28,6 +30,8 @@ export const schema = { ...dishCategories, ...manyToMany, ...files, + ...orderDishes, + ...orderDeliveries, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts index 1ff9494..a184e51 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -3,6 +3,7 @@ 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"; @@ -133,4 +134,5 @@ export const dishRelations = relations(dishes, ({ many }) => ({ dishesToImages: many(dishesToImages), dishesToWorkshops: many(dishesToWorkshops), dishesToRestaurants: many(dishesToRestaurants), + orderDishes: many(orderDishes), })); diff --git a/src/@base/drizzle/schema/guests.ts b/src/@base/drizzle/schema/guests.ts index 967f241..8382557 100644 --- a/src/@base/drizzle/schema/guests.ts +++ b/src/@base/drizzle/schema/guests.ts @@ -1,3 +1,5 @@ +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", { @@ -6,9 +8,13 @@ export const guests = pgTable("guests", { phone: text("phone").unique().notNull(), email: text("email").unique(), bonusBalance: integer("bonusBalance").notNull().default(0), - lastVisitAt: timestamp("onlineAt").notNull().defaultNow(), + lastVisitAt: timestamp("lastVisitAt").notNull().defaultNow(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); export type IGuest = typeof guests.$inferSelect; + +export const guestRelations = relations(guests, ({ many }) => ({ + orders: many(orders), +})); diff --git a/src/@base/drizzle/schema/order-deliveries.ts b/src/@base/drizzle/schema/order-deliveries.ts new file mode 100644 index 0000000..f3d0f45 --- /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("orderDeliveryStatusEnum", [ + "pending", + "dispatched", + "delivered", +]); + +export const ZodOrderDeliveryStatusEnum = z.enum( + orderDeliveryStatusEnum.enumValues, +); + +export type OrderDeliveryStatusEnum = typeof ZodOrderDeliveryStatusEnum._type; + +export const orderDeliveries = pgTable("orderDeliveries", { + id: uuid("id").defaultRandom().primaryKey(), + + // Relation fields // + orderId: uuid("orderId").notNull(), + workerId: uuid("workerId"), + + // 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("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), + dispatchedAt: timestamp("dispatchedAt"), + estimatedDeliveryAt: timestamp("estimatedDeliveryAt"), + deliveredAt: timestamp("deliveredAt"), +}); + +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..57b534d --- /dev/null +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -0,0 +1,81 @@ +import { dishes } from "@postgress-db/schema/dishes"; +import { orders } from "@postgress-db/schema/orders"; +import { relations } from "drizzle-orm"; +import { + boolean, + decimal, + integer, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { z } from "zod"; + +export const orderDishStatusEnum = pgEnum("orderDishStatusEnum", [ + "pending", + "cooking", + "ready", + "completed", +]); + +export const ZodOrderDishStatusEnum = z.enum(orderDishStatusEnum.enumValues); + +export type OrderDishStatusEnum = typeof ZodOrderDishStatusEnum._type; + +export const orderDishes = pgTable("orderDishes", { + id: uuid("id").defaultRandom().primaryKey(), + + // Relation fields // + orderId: uuid("orderId").notNull(), + dishId: uuid("dishId").notNull(), + discountId: uuid("discountId"), + surchargeId: uuid("surchargeId"), + + // Data // + name: text("name").notNull(), + status: orderDishStatusEnum("status").notNull(), + + // Quantity // + quantity: integer("quantity").notNull(), + quantityReturned: integer("quantityReturned").notNull().default(0), + + // Price info // + price: decimal("price", { precision: 10, scale: 2 }).notNull(), + discountPercent: decimal("discountPercent", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + discountAmount: decimal("discountAmount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargePercent: decimal("surchargePercent", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargeAmount: decimal("surchargeAmount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + finalPrice: decimal("finalPrice", { precision: 10, scale: 2 }).notNull(), + + // Booleans flags // + isRemoved: boolean("isRemoved").notNull().default(false), + isAdditional: boolean("isAdditional").notNull().default(false), + + // Timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), + removedAt: timestamp("removedAt"), +}); + +export type IOrderDish = typeof orderDishes.$inferSelect; + +export const orderDishRelations = relations(orderDishes, ({ one }) => ({ + order: one(orders, { + fields: [orderDishes.orderId], + references: [orders.id], + }), + dish: one(dishes, { + fields: [orderDishes.dishId], + references: [dishes.id], + }), +})); diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts index 9c286c6..b6a86c4 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -1,7 +1,12 @@ +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 { restaurants } from "@postgress-db/schema/restaurants"; import { relations } from "drizzle-orm"; import { boolean, + decimal, integer, pgEnum, pgTable, @@ -11,6 +16,30 @@ import { } from "drizzle-orm/pg-core"; import { z } from "zod"; +export const orderFromEnum = pgEnum("orderFromEnum", [ + "app", + "website", + "internal", +]); + +export const ZodOrderFromEnum = z.enum(orderFromEnum.enumValues); + +export type OrderFromEnum = typeof ZodOrderFromEnum._type; + +export const orderStatusEnum = pgEnum("orderStatusEnum", [ + "pending", + "cooking", + "ready", + "deliverying", + "paid", + "completed", + "cancelled", +]); + +export const ZodOrderStatusEnum = z.enum(orderStatusEnum.enumValues); + +export type OrderStatusEnum = typeof ZodOrderStatusEnum._type; + export const orderTypeEnum = pgEnum("orderTypeEnum", [ "hall", "banquet", @@ -26,45 +55,65 @@ export const orders = pgTable("orders", { id: uuid("id").defaultRandom().primaryKey(), // Links // + guestId: uuid("guestId"), restaurantId: uuid("restaurantId"), // Order number // number: integer("number").notNull(), - - // Table number // tableNumber: text("tableNumber"), // Order type // type: orderTypeEnum("type").notNull(), + status: orderStatusEnum("status").notNull(), + currency: currencyEnum("currency").notNull(), // Note from the admins // - note: text("note").notNull().default(""), + note: text("note"), // Guest information // - guestId: uuid("guestId"), - phone: text("phone"), guestName: text("guestName"), - - // Guests amount // + guestPhone: text("guestPhone"), guestsAmount: integer("guestsAmount"), + // Price info // + subtotal: decimal("subtotal", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + discountAmount: decimal("discountAmount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargeAmount: decimal("surchargeAmount", { 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 // - isHidden: boolean("isHidden").notNull().default(false), - isAppOrder: boolean("isAppOrder").notNull().default(false), + isHiddenForGuest: boolean("isHiddenForGuest").notNull().default(false), + isRemoved: boolean("isRemoved").notNull().default(false), // Default timestamps createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), - deliveredAt: timestamp("deliveredAt"), - archivedAt: timestamp("archivedAt"), removedAt: timestamp("removedAt"), }); export type IOrder = typeof orders.$inferSelect; -export const orderRelations = relations(orders, ({ one }) => ({ +export const orderRelations = relations(orders, ({ one, many }) => ({ + 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/workers.ts b/src/@base/drizzle/schema/workers.ts index e93aa26..0b68e83 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -1,3 +1,4 @@ +import { orderDeliveries } from "@postgress-db/schema/order-deliveries"; import { relations } from "drizzle-orm"; import { boolean, @@ -48,6 +49,7 @@ export const workerRelations = relations(workers, ({ one, many }) => ({ }), sessions: many(sessions), workshops: many(workshopWorkers), + deliveries: many(orderDeliveries), })); export type IWorker = typeof workers.$inferSelect; From 925fc21441babcc6a537f2b24405e0b3162d6743 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 20 Jan 2025 12:39:51 +0200 Subject: [PATCH 049/180] feat: seed for workers and restaurants --- nest-cli.json | 2 +- package.json | 2 +- src/@base/drizzle/seed.ts | 30 ----- src/auth/services/auth.service.ts | 2 +- test/helpers/clear-db.ts | 23 ---- test/helpers/consts.ts | 2 +- test/helpers/create-admin.ts | 15 --- test/helpers/create-role-worker.ts | 20 --- test/helpers/database/index.ts | 86 +++++++++++++ test/helpers/db.ts | 10 -- test/helpers/migrate-db.ts | 14 --- test/helpers/mock/workers.ts | 29 ----- test/helpers/seed.ts | 28 ----- test/helpers/seed/cache.json | 94 ++++++++++++++ test/helpers/seed/index.ts | 29 +++++ test/helpers/seed/restaurants.ts | 194 +++++++++++++++++++++++++++++ test/helpers/seed/workers.ts | 65 ++++++++++ test/helpers/sign-in.ts | 12 +- tsconfig.json | 2 +- 19 files changed, 481 insertions(+), 178 deletions(-) delete mode 100644 src/@base/drizzle/seed.ts delete mode 100644 test/helpers/clear-db.ts delete mode 100644 test/helpers/create-admin.ts delete mode 100644 test/helpers/create-role-worker.ts create mode 100644 test/helpers/database/index.ts delete mode 100644 test/helpers/db.ts delete mode 100644 test/helpers/migrate-db.ts delete mode 100644 test/helpers/mock/workers.ts delete mode 100644 test/helpers/seed.ts create mode 100644 test/helpers/seed/cache.json create mode 100644 test/helpers/seed/index.ts create mode 100644 test/helpers/seed/restaurants.ts create mode 100644 test/helpers/seed/workers.ts diff --git a/nest-cli.json b/nest-cli.json index f480375..a9a0e67 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -7,7 +7,7 @@ { "include": "i18n/messages/**/*", "watchAssets": true, - "outDir": "dist/src" + "outDir": "dist" } ], "deleteOutDir": true diff --git a/package.json b/package.json index eab1358..46b7ab9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "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 src/@base/drizzle/seed.ts" + "db:seed": "node -r esbuild-register test/helpers/seed/index.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.726.1", diff --git a/src/@base/drizzle/seed.ts b/src/@base/drizzle/seed.ts deleted file mode 100644 index 62ec8bd..0000000 --- a/src/@base/drizzle/seed.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { schema } from "@postgress-db/drizzle.module"; -import dotenv from "dotenv"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; -import { seedDatabase } from "test/helpers/seed"; - -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/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index eed864d..08cc5f0 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,12 +1,12 @@ import { UnauthorizedException } from "@core/errors/exceptions/unauthorized.exception"; import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; import * as argon2 from "argon2"; 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 { schema } from "test/helpers/db"; import { SessionsService } from "./sessions.service"; 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..dbdd4ab --- /dev/null +++ b/test/helpers/database/index.ts @@ -0,0 +1,86 @@ +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 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, + ...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 bd5b49f..0000000 --- a/test/helpers/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { schema } from "@postgress-db/drizzle.module"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; - -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..676ee62 --- /dev/null +++ b/test/helpers/seed/cache.json @@ -0,0 +1,94 @@ +{ + "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" + } + ] +} \ No newline at end of file diff --git a/test/helpers/seed/index.ts b/test/helpers/seed/index.ts new file mode 100644 index 0000000..8331cb7 --- /dev/null +++ b/test/helpers/seed/index.ts @@ -0,0 +1,29 @@ +import { DatabaseHelper } from "test/helpers/database"; +import seedRestaurants from "test/helpers/seed/restaurants"; +import seedWorkers, { createSystemAdmin } from "test/helpers/seed/workers"; +import "dotenv/config"; + +interface SeedVariantData { + workers: number; + restaurants: number; +} + +type SeedVariant = "mini"; + +const variants: Record = { + mini: { + workers: 50, + restaurants: 10, + }, +}; + +async function seed(variant: SeedVariantData) { + await DatabaseHelper.truncateAll(); + await DatabaseHelper.push(); + + await createSystemAdmin(); + await seedRestaurants(variant.restaurants); + await seedWorkers(variant.workers); +} + +seed(variants.mini); 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/tsconfig.json b/tsconfig.json index 50c5fcd..eb5bc35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,6 @@ "@postgress-db/*": ["src/@base/drizzle/*"], }, }, - "include": ["src/**/*"], + "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules", "dist"], } From 8c966d022285ae5c65895884bfcec3118a118e0d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 20 Jan 2025 18:17:38 +0200 Subject: [PATCH 050/180] feat: create and find methods for orders --- src/@base/drizzle/drizzle.module.ts | 2 + src/@base/drizzle/schema/general.ts | 5 + src/@base/drizzle/schema/orders.ts | 10 +- src/app.module.ts | 2 + src/guests/guests.service.ts | 20 +- src/i18n/messages/en/validation.json | 4 +- src/i18n/messages/et/validation.json | 4 +- src/i18n/messages/ru/validation.json | 4 +- src/i18n/validators/index.ts | 32 +++ src/orders/@/dtos/create-order.dto.ts | 17 ++ src/orders/@/dtos/orders-paginated.dto.ts | 15 ++ src/orders/@/dtos/update-order.dto.ts | 4 + src/orders/@/entities/order.entity.ts | 226 ++++++++++++++++++++++ src/orders/@/orders.controller.ts | 102 ++++++++++ src/orders/@/orders.service.ts | 195 +++++++++++++++++++ src/orders/orders.module.ts | 13 ++ 16 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 src/orders/@/dtos/create-order.dto.ts create mode 100644 src/orders/@/dtos/orders-paginated.dto.ts create mode 100644 src/orders/@/dtos/update-order.dto.ts create mode 100644 src/orders/@/entities/order.entity.ts create mode 100644 src/orders/@/orders.controller.ts create mode 100644 src/orders/@/orders.service.ts create mode 100644 src/orders/orders.module.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index 19f8c59..eb7e314 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -14,6 +14,7 @@ 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 restaurantWorkshops from "./schema/restaurant-workshop"; import * as restaurants from "./schema/restaurants"; import * as sessions from "./schema/sessions"; @@ -32,6 +33,7 @@ export const schema = { ...files, ...orderDishes, ...orderDeliveries, + ...orders, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/general.ts b/src/@base/drizzle/schema/general.ts index eb742a9..2251dd0 100644 --- a/src/@base/drizzle/schema/general.ts +++ b/src/@base/drizzle/schema/general.ts @@ -1,4 +1,5 @@ import { pgEnum } from "drizzle-orm/pg-core"; +import { z } from "zod"; export const dayOfWeekEnum = pgEnum("day_of_week", [ "monday", @@ -12,4 +13,8 @@ export const dayOfWeekEnum = pgEnum("day_of_week", [ 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/orders.ts b/src/@base/drizzle/schema/orders.ts index b6a86c4..6d58c55 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -51,6 +51,12 @@ export const ZodOrderTypeEnum = z.enum(orderTypeEnum.enumValues); export type OrderTypeEnum = typeof ZodOrderTypeEnum._type; +export const orderNumberBroneering = pgTable("orderNumberBroneering", { + id: uuid("id").defaultRandom().primaryKey(), + number: text("number").notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + export const orders = pgTable("orders", { id: uuid("id").defaultRandom().primaryKey(), @@ -59,13 +65,14 @@ export const orders = pgTable("orders", { restaurantId: uuid("restaurantId"), // Order number // - number: integer("number").notNull(), + number: text("number").notNull(), tableNumber: text("tableNumber"), // 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"), @@ -98,6 +105,7 @@ export const orders = pgTable("orders", { createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), removedAt: timestamp("removedAt"), + delayedTo: timestamp("delayedTo"), }); export type IOrder = typeof orders.$inferSelect; diff --git a/src/app.module.ts b/src/app.module.ts index ed8d898..529ba5e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { DishCategoriesModule } from "src/dish-categories/dish-categories.module import { DishesModule } from "src/dishes/dishes.module"; import { FilesModule } from "src/files/files.module"; import { GuestsModule } from "src/guests/guests.module"; +import { OrdersModule } from "src/orders/orders.module"; import { TimezonesModule } from "src/timezones/timezones.module"; import { DrizzleModule } from "./@base/drizzle/drizzle.module"; @@ -65,6 +66,7 @@ import { WorkersModule } from "./workers/workers.module"; S3Module, FilesModule, NestjsFormDataModule, + OrdersModule, I18nModule.forRoot({ fallbackLanguage: "en", loaderOptions: { diff --git a/src/guests/guests.service.ts b/src/guests/guests.service.ts index 3cfa3d2..dd3b3dd 100644 --- a/src/guests/guests.service.ts +++ b/src/guests/guests.service.ts @@ -142,6 +142,24 @@ export class GuestsService { .where(eq(schema.guests.id, id)) .limit(1); - return result[0]; + 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/validation.json b/src/i18n/messages/en/validation.json index 06f7614..561cfb2 100644 --- a/src/i18n/messages/en/validation.json +++ b/src/i18n/messages/en/validation.json @@ -17,6 +17,8 @@ "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" + "isLatitude": "Must be a valid latitude", + "isInt": "Must be an integer", + "isDecimal": "Must be a decimal number" } } diff --git a/src/i18n/messages/et/validation.json b/src/i18n/messages/et/validation.json index 815644e..a2a3022 100644 --- a/src/i18n/messages/et/validation.json +++ b/src/i18n/messages/et/validation.json @@ -17,6 +17,8 @@ "isPhoneNumber": "Peab olema kehtiv telefoninumber", "isTimeFormat": "Peab olema HH:MM vormingus (24-tunni)", "isStrongPassword": "Peab olema tugev parool", - "isLatitude": "Peab olema kehtiv laiuskraad" + "isLatitude": "Peab olema kehtiv laiuskraad", + "isInt": "Peab olema täisarv", + "isDecimal": "Peab olema kümnendarv" } } diff --git a/src/i18n/messages/ru/validation.json b/src/i18n/messages/ru/validation.json index a11544b..01e24e8 100644 --- a/src/i18n/messages/ru/validation.json +++ b/src/i18n/messages/ru/validation.json @@ -17,6 +17,8 @@ "isPhoneNumber": "Должен быть действительный номер телефона", "isTimeFormat": "Должно быть в формате ЧЧ:ММ (24-часовой формат)", "isStrongPassword": "Должно быть сильным паролем", - "isLatitude": "Должно быть действительной широтой" + "isLatitude": "Должно быть действительной широтой", + "isInt": "Должно быть целым числом", + "isDecimal": "Должно быть десятичным числом" } } diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts index b87e751..91c9de4 100644 --- a/src/i18n/validators/index.ts +++ b/src/i18n/validators/index.ts @@ -4,7 +4,9 @@ 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, IsNumber as _IsNumber, @@ -17,6 +19,7 @@ import { ValidationOptions, } from "class-validator"; import { i18nValidationMessage } from "nestjs-i18n"; +import { DecimalLocale } from "validator"; // eslint-disable-next-line no-restricted-imports export { @@ -108,3 +111,32 @@ export const IsStrongPassword = ( 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)), + ); diff --git a/src/orders/@/dtos/create-order.dto.ts b/src/orders/@/dtos/create-order.dto.ts new file mode 100644 index 0000000..0672127 --- /dev/null +++ b/src/orders/@/dtos/create-order.dto.ts @@ -0,0 +1,17 @@ +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", + ]), + ), +) {} 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/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.entity.ts b/src/orders/@/entities/order.entity.ts new file mode 100644 index 0000000..102c2b1 --- /dev/null +++ b/src/orders/@/entities/order.entity.ts @@ -0,0 +1,226 @@ +import { + IsBoolean, + IsDate, + IsDecimal, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, +} 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 } from "class-transformer"; + +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; + + @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; + + @IsString() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Guest phone number", + example: "+372 5555 5555", + }) + guestPhone: string | null; + + @IsInt() + @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; + + @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; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Removal timestamp", + example: null, + }) + removedAt: Date | null; + + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Delayed to timestamp", + example: null, + }) + delayedTo: Date | null; +} diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts new file mode 100644 index 0000000..ad47556 --- /dev/null +++ b/src/orders/@/orders.controller.ts @@ -0,0 +1,102 @@ +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 { Body, Get, Param, Post } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, +} from "@nestjs/swagger"; +import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { OrdersService } from "src/orders/@/orders.service"; + +import { OrdersPaginatedDto } from "./dtos/orders-paginated.dto"; + +@Controller("orders") +export class OrdersController { + constructor(private readonly ordersService: OrdersService) {} + + @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); + } + + @Get() + @ApiOperation({ + summary: "Gets orders that available in system", + }) + @ApiOkResponse({ + description: "Orders have been successfully fetched", + type: OrdersPaginatedDto, + }) + async findMany( + @SortingParams({ + fields: [ + "id", + "number", + "type", + "status", + "guestName", + "total", + "createdAt", + "updatedAt", + ], + }) + sorting: ISorting, + @PaginationParams() pagination: IPagination, + @FilterParams() filters?: IFilters, + ): Promise { + const total = await this.ordersService.getTotalCount(filters); + const data = await this.ordersService.findMany({ + pagination, + sorting, + filters, + }); + + return { + data, + meta: { + ...pagination, + total, + }, + }; + } + + @Post() + @Serializable(OrderEntity) + @ApiOperation({ summary: "Creates a new order" }) + @ApiCreatedResponse({ + description: "Order has been successfully created", + type: OrderEntity, + }) + async create(@Body() dto: CreateOrderDto) { + return this.ordersService.create(dto); + } +} diff --git a/src/orders/@/orders.service.ts b/src/orders/@/orders.service.ts new file mode 100644 index 0000000..c9d2767 --- /dev/null +++ b/src/orders/@/orders.service.ts @@ -0,0 +1,195 @@ +import { IFilters } from "@core/decorators/filter.decorator"; +import { IPagination } from "@core/decorators/pagination.decorator"; +import { ISorting } from "@core/decorators/sorting.decorator"; +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 { asc, count, desc, eq, sql } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { AnyPgSelect, PgSelectPrepare } from "drizzle-orm/pg-core"; +import { PG_CONNECTION } from "src/constants"; +import { GuestsService } from "src/guests/guests.service"; +import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +@Injectable() +export class OrdersService { + private readonly findByIdQuery: PgSelectPrepare; + + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + private readonly guestsService: GuestsService, + ) { + this.findByIdQuery = this.pg + .select({ + id: orders.id, + number: orders.number, + tableNumber: orders.tableNumber, + type: orders.type, + status: orders.status, + currency: orders.currency, + from: orders.from, + note: orders.note, + guestName: orders.guestName, + guestPhone: orders.guestPhone, + guestsAmount: orders.guestsAmount, + subtotal: orders.subtotal, + discountAmount: orders.discountAmount, + surchargeAmount: orders.surchargeAmount, + bonusUsed: orders.bonusUsed, + total: orders.total, + isHiddenForGuest: orders.isHiddenForGuest, + isRemoved: orders.isRemoved, + createdAt: orders.createdAt, + updatedAt: orders.updatedAt, + removedAt: orders.removedAt, + delayedTo: orders.delayedTo, + restaurantId: orders.restaurantId, + guestId: orders.guestId, + }) + .from(orders) + .where(eq(orders.id, sql.placeholder("id"))) + .limit(1) + .prepare("find_order_by_id"); + } + + 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; + } + + async create(dto: CreateOrderDto): Promise { + const { + type, + guestName, + guestPhone, + guestsAmount, + note, + delayedTo, + restaurantId, + tableNumber, + } = dto; + + const number = await this.generateOrderNumber(); + const guest = await this.guestsService.findByPhoneNumber(guestPhone); + + const [order] = await this.pg + .insert(orders) + .values({ + number, + tableNumber, + type, + from: "internal", + status: "pending", + currency: "RUB", + delayedTo, + guestsAmount, + note, + restaurantId, + + // Guest info // + guestId: guest?.id, + guestName: guestName ?? guest?.name, + guestPhone, + }) + .returning(); + + return order; + } + + public async getTotalCount(filters?: IFilters): Promise { + const query = this.pg + .select({ + value: count(), + }) + .from(orders); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(orders, filters)); + } + + return await query.then((res) => res[0].value); + } + + public async findMany(options?: { + pagination?: IPagination; + sorting?: ISorting; + filters?: IFilters; + }): Promise { + const { pagination, sorting, filters } = options ?? {}; + + const query = this.pg + .select({ + id: orders.id, + number: orders.number, + tableNumber: orders.tableNumber, + type: orders.type, + status: orders.status, + currency: orders.currency, + from: orders.from, + note: orders.note, + guestName: orders.guestName, + guestPhone: orders.guestPhone, + guestsAmount: orders.guestsAmount, + subtotal: orders.subtotal, + discountAmount: orders.discountAmount, + surchargeAmount: orders.surchargeAmount, + bonusUsed: orders.bonusUsed, + total: orders.total, + isHiddenForGuest: orders.isHiddenForGuest, + isRemoved: orders.isRemoved, + createdAt: orders.createdAt, + updatedAt: orders.updatedAt, + removedAt: orders.removedAt, + delayedTo: orders.delayedTo, + restaurantId: orders.restaurantId, + guestId: orders.guestId, + }) + .from(orders); + + if (filters) { + query.where(DrizzleUtils.buildFilterConditions(orders, filters)); + } + + if (sorting) { + query.orderBy( + sorting.sortOrder === "asc" + ? asc(sql.identifier(sorting.sortBy)) + : desc(sql.identifier(sorting.sortBy)), + ); + } + + return await query + .limit(pagination?.size ?? 10) + .offset(pagination?.offset ?? 0); + } + + public async findById(id: string): Promise { + const result = await this.findByIdQuery.execute({ id }); + + const order = result[0]; + + if (!order) { + throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); + } + + return order; + } +} diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts new file mode 100644 index 0000000..d24b303 --- /dev/null +++ b/src/orders/orders.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { GuestsModule } from "src/guests/guests.module"; +import { OrdersController } from "src/orders/@/orders.controller"; +import { OrdersService } from "src/orders/@/orders.service"; + +@Module({ + imports: [DrizzleModule, GuestsModule], + providers: [OrdersService], + controllers: [OrdersController], + exports: [OrdersService], +}) +export class OrdersModule {} From 283851fe78c8574d3dbf049b59a59b440d355b19 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 21 Jan 2025 15:21:21 +0200 Subject: [PATCH 051/180] feat: attach restaurant name to the orders --- src/@base/drizzle/schema/orders.ts | 2 +- src/orders/@/entities/order.entity.ts | 12 ++++++- src/orders/@/orders.service.ts | 49 ++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts index 6d58c55..bf116fc 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -30,7 +30,7 @@ export const orderStatusEnum = pgEnum("orderStatusEnum", [ "pending", "cooking", "ready", - "deliverying", + "delivering", "paid", "completed", "cancelled", diff --git a/src/orders/@/entities/order.entity.ts b/src/orders/@/entities/order.entity.ts index 102c2b1..2e4f3cc 100644 --- a/src/orders/@/entities/order.entity.ts +++ b/src/orders/@/entities/order.entity.ts @@ -4,6 +4,7 @@ import { IsDecimal, IsEnum, IsInt, + IsISO8601, IsOptional, IsString, IsUUID, @@ -45,6 +46,15 @@ export class OrderEntity implements IOrder { }) restaurantId: string | null; + @IsString() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Restaurant name", + example: "Downtown Restaurant", + }) + restaurantName?: string | null; + @IsString() @Expose() @ApiProperty({ @@ -215,7 +225,7 @@ export class OrderEntity implements IOrder { }) removedAt: Date | null; - @IsDate() + @IsISO8601() @IsOptional() @Expose() @ApiPropertyOptional({ diff --git a/src/orders/@/orders.service.ts b/src/orders/@/orders.service.ts index c9d2767..e905697 100644 --- a/src/orders/@/orders.service.ts +++ b/src/orders/@/orders.service.ts @@ -6,7 +6,8 @@ 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 { asc, count, desc, eq, sql } from "drizzle-orm"; +import { restaurants } from "@postgress-db/schema/restaurants"; +import { asc, count, desc, eq, inArray, sql } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { AnyPgSelect, PgSelectPrepare } from "drizzle-orm/pg-core"; import { PG_CONNECTION } from "src/constants"; @@ -99,7 +100,7 @@ export class OrdersService { from: "internal", status: "pending", currency: "RUB", - delayedTo, + ...(delayedTo ? { delayedTo: new Date(delayedTo) } : {}), guestsAmount, note, restaurantId, @@ -128,6 +129,43 @@ export class OrdersService { return await query.then((res) => res[0].value); } + private async attachRestaurantNames( + orders: OrderEntity[], + ): Promise { + // Get unique restaurant IDs from orders + const restaurantIds = [ + ...new Set( + orders.filter((o) => o.restaurantId).map((o) => o.restaurantId), + ), + ].filter(Boolean) as string[]; + + if (restaurantIds.length === 0) { + return orders; + } + + // Fetch all relevant restaurants in one query + const restaurantsResult = await this.pg + .select({ + id: restaurants.id, + name: restaurants.name, + }) + .from(restaurants) + .where(inArray(restaurants.id, restaurantIds)); + + // Create a map for quick lookups + const restaurantMap = new Map( + restaurantsResult.map((restaurant) => [restaurant.id, restaurant.name]), + ); + + // Attach restaurant names to orders + return orders.map((order) => ({ + ...order, + restaurantName: order.restaurantId + ? restaurantMap.get(order.restaurantId) ?? null + : null, + })); + } + public async findMany(options?: { pagination?: IPagination; sorting?: ISorting; @@ -176,9 +214,11 @@ export class OrdersService { ); } - return await query + const _orders = await query .limit(pagination?.size ?? 10) .offset(pagination?.offset ?? 0); + + return this.attachRestaurantNames(_orders); } public async findById(id: string): Promise { @@ -190,6 +230,7 @@ export class OrdersService { throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); } - return order; + const [orderWithRestaurant] = await this.attachRestaurantNames([order]); + return orderWithRestaurant; } } From 009c45904e55465458019647731a8d7ed28c0752 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 22 Jan 2025 18:19:06 +0200 Subject: [PATCH 052/180] feat: dispatcher orders endpoint testing --- src/@base/drizzle/schema/order-dishes.ts | 77 ++++---- src/@base/drizzle/schema/orders.ts | 112 ++++++----- src/@core/decorators/cursor.decorator.ts | 65 +++++++ src/i18n/messages/en/errors.json | 5 +- src/i18n/messages/ru/errors.json | 5 +- src/orders/@/dtos/kitchen-order-dish.dto.ts | 28 +++ src/orders/@/entities/order-dish.entity.ts | 184 ++++++++++++++++++ src/orders/@/entities/order.entity.ts | 21 +- src/orders/@/orders.controller.ts | 41 ---- src/orders/@/orders.service.ts | 135 +++++++------ .../dispatcher-orders.controller.ts | 41 ++++ .../dispatcher/dispatcher-orders.service.ts | 55 ++++++ .../entities/dispatcher-order-dish.entity.ts | 6 + .../entities/dispatcher-order.entity.ts | 19 ++ .../dispatcher-orders-paginated.entity.ts | 14 ++ src/orders/orders.module.ts | 6 +- test/helpers/database/index.ts | 2 + test/helpers/seed/cache.json | 18 ++ test/helpers/seed/dishes.ts | 24 +++ test/helpers/seed/index.ts | 17 ++ test/helpers/seed/orders.ts | 184 ++++++++++++++++++ 21 files changed, 867 insertions(+), 192 deletions(-) create mode 100644 src/@core/decorators/cursor.decorator.ts create mode 100644 src/orders/@/dtos/kitchen-order-dish.dto.ts create mode 100644 src/orders/@/entities/order-dish.entity.ts create mode 100644 src/orders/dispatcher/dispatcher-orders.controller.ts create mode 100644 src/orders/dispatcher/dispatcher-orders.service.ts create mode 100644 src/orders/dispatcher/entities/dispatcher-order-dish.entity.ts create mode 100644 src/orders/dispatcher/entities/dispatcher-order.entity.ts create mode 100644 src/orders/dispatcher/entities/dispatcher-orders-paginated.entity.ts create mode 100644 test/helpers/seed/dishes.ts create mode 100644 test/helpers/seed/orders.ts diff --git a/src/@base/drizzle/schema/order-dishes.ts b/src/@base/drizzle/schema/order-dishes.ts index 57b534d..c0071c2 100644 --- a/src/@base/drizzle/schema/order-dishes.ts +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -4,6 +4,7 @@ import { relations } from "drizzle-orm"; import { boolean, decimal, + index, integer, pgEnum, pgTable, @@ -24,48 +25,52 @@ export const ZodOrderDishStatusEnum = z.enum(orderDishStatusEnum.enumValues); export type OrderDishStatusEnum = typeof ZodOrderDishStatusEnum._type; -export const orderDishes = pgTable("orderDishes", { - id: uuid("id").defaultRandom().primaryKey(), +export const orderDishes = pgTable( + "orderDishes", + { + id: uuid("id").defaultRandom().primaryKey(), - // Relation fields // - orderId: uuid("orderId").notNull(), - dishId: uuid("dishId").notNull(), - discountId: uuid("discountId"), - surchargeId: uuid("surchargeId"), + // Relation fields // + orderId: uuid("orderId").notNull(), + dishId: uuid("dishId").notNull(), + discountId: uuid("discountId"), + surchargeId: uuid("surchargeId"), - // Data // - name: text("name").notNull(), - status: orderDishStatusEnum("status").notNull(), + // Data // + name: text("name").notNull(), + status: orderDishStatusEnum("status").notNull(), - // Quantity // - quantity: integer("quantity").notNull(), - quantityReturned: integer("quantityReturned").notNull().default(0), + // Quantity // + quantity: integer("quantity").notNull(), + quantityReturned: integer("quantityReturned").notNull().default(0), - // Price info // - price: decimal("price", { precision: 10, scale: 2 }).notNull(), - discountPercent: decimal("discountPercent", { precision: 10, scale: 2 }) - .notNull() - .default("0"), - discountAmount: decimal("discountAmount", { precision: 10, scale: 2 }) - .notNull() - .default("0"), - surchargePercent: decimal("surchargePercent", { precision: 10, scale: 2 }) - .notNull() - .default("0"), - surchargeAmount: decimal("surchargeAmount", { precision: 10, scale: 2 }) - .notNull() - .default("0"), - finalPrice: decimal("finalPrice", { precision: 10, scale: 2 }).notNull(), + // Price info // + price: decimal("price", { precision: 10, scale: 2 }).notNull(), + discountPercent: decimal("discountPercent", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + discountAmount: decimal("discountAmount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargePercent: decimal("surchargePercent", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargeAmount: decimal("surchargeAmount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + finalPrice: decimal("finalPrice", { precision: 10, scale: 2 }).notNull(), - // Booleans flags // - isRemoved: boolean("isRemoved").notNull().default(false), - isAdditional: boolean("isAdditional").notNull().default(false), + // Booleans flags // + isRemoved: boolean("isRemoved").notNull().default(false), + isAdditional: boolean("isAdditional").notNull().default(false), - // Timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), - removedAt: timestamp("removedAt"), -}); + // Timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), + removedAt: timestamp("removedAt"), + }, + (table) => [index("orderDishes_orderId_idx").on(table.orderId)], +); export type IOrderDish = typeof orderDishes.$inferSelect; diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts index bf116fc..716ceb3 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -7,6 +7,7 @@ import { relations } from "drizzle-orm"; import { boolean, decimal, + index, integer, pgEnum, pgTable, @@ -57,56 +58,67 @@ export const orderNumberBroneering = pgTable("orderNumberBroneering", { createdAt: timestamp("createdAt").notNull().defaultNow(), }); -export const orders = pgTable("orders", { - id: uuid("id").defaultRandom().primaryKey(), - - // Links // - guestId: uuid("guestId"), - restaurantId: uuid("restaurantId"), - - // Order number // - number: text("number").notNull(), - tableNumber: text("tableNumber"), - - // 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("guestName"), - guestPhone: text("guestPhone"), - guestsAmount: integer("guestsAmount"), - - // Price info // - subtotal: decimal("subtotal", { precision: 10, scale: 2 }) - .notNull() - .default("0"), - discountAmount: decimal("discountAmount", { precision: 10, scale: 2 }) - .notNull() - .default("0"), - surchargeAmount: decimal("surchargeAmount", { 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("isHiddenForGuest").notNull().default(false), - isRemoved: boolean("isRemoved").notNull().default(false), - - // Default timestamps - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), - removedAt: timestamp("removedAt"), - delayedTo: timestamp("delayedTo"), -}); +export const orders = pgTable( + "orders", + { + id: uuid("id").defaultRandom().primaryKey(), + + // Links // + guestId: uuid("guestId"), + restaurantId: uuid("restaurantId"), + + // Order number // + number: text("number").notNull(), + tableNumber: text("tableNumber"), + + // 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("guestName"), + guestPhone: text("guestPhone"), + guestsAmount: integer("guestsAmount"), + + // Price info // + subtotal: decimal("subtotal", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + discountAmount: decimal("discountAmount", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + surchargeAmount: decimal("surchargeAmount", { 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("isHiddenForGuest").notNull().default(false), + isRemoved: boolean("isRemoved").notNull().default(false), + isArchived: boolean("isArchived").notNull().default(false), + + // Default timestamps + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), + removedAt: timestamp("removedAt"), + delayedTo: timestamp("delayedTo"), + }, + (table) => [ + index("orders_restaurantId_idx").on(table.restaurantId), + index("orders_created_at_idx").on(table.createdAt), + index("orders_isArchived_idx").on(table.isArchived), + index("orders_isRemoved_idx").on(table.isRemoved), + index("order_id_and_created_at_idx").on(table.id, table.createdAt), + ], +); export type IOrder = typeof orders.$inferSelect; 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/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 199a3df..561ca12 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -22,7 +22,10 @@ "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-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" diff --git a/src/i18n/messages/ru/errors.json b/src/i18n/messages/ru/errors.json index 6287e0a..4c57bb6 100644 --- a/src/i18n/messages/ru/errors.json +++ b/src/i18n/messages/ru/errors.json @@ -20,7 +20,10 @@ "invalid-pagination-params": "Неверные параметры пагинации", "invalid-filters-format": "Неверный формат фильтров: должен быть JSON строкой или массивом", "invalid-filter-condition": "Неверное условие фильтра", - "invalid-filter-format": "Неверный формат фильтра: каждый фильтр должен иметь поле, значение и условие" + "invalid-filter-format": "Неверный формат фильтра: каждый фильтр должен иметь поле, значение и условие", + "invalid-limit-value": "Неверное значение лимита", + "invalid-cursor-id": "Неверный идентификатор курсора", + "limit-too-big": "Лимит не может быть больше 1000" }, "auth": { "invalid-credentials": "Неверные учетные данные" 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/@/entities/order-dish.entity.ts b/src/orders/@/entities/order-dish.entity.ts new file mode 100644 index 0000000..b9e12de --- /dev/null +++ b/src/orders/@/entities/order-dish.entity.ts @@ -0,0 +1,184 @@ +import { + 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 } from "class-transformer"; + +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; + + @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: "Removal timestamp", + example: null, + }) + removedAt: Date | null; +} diff --git a/src/orders/@/entities/order.entity.ts b/src/orders/@/entities/order.entity.ts index 2e4f3cc..0aa846a 100644 --- a/src/orders/@/entities/order.entity.ts +++ b/src/orders/@/entities/order.entity.ts @@ -17,7 +17,8 @@ import { ZodOrderStatusEnum, ZodOrderTypeEnum, } from "@postgress-db/schema/orders"; -import { Expose } from "class-transformer"; +import { Expose, Type } from "class-transformer"; +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; export class OrderEntity implements IOrder { @IsUUID() @@ -53,7 +54,7 @@ export class OrderEntity implements IOrder { description: "Restaurant name", example: "Downtown Restaurant", }) - restaurantName?: string | null; + restaurantName: string | null; @IsString() @Expose() @@ -200,6 +201,22 @@ export class OrderEntity implements IOrder { }) 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({ diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index ad47556..65cfe8b 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -48,47 +48,6 @@ export class OrdersController { return await this.ordersService.findById(id); } - @Get() - @ApiOperation({ - summary: "Gets orders that available in system", - }) - @ApiOkResponse({ - description: "Orders have been successfully fetched", - type: OrdersPaginatedDto, - }) - async findMany( - @SortingParams({ - fields: [ - "id", - "number", - "type", - "status", - "guestName", - "total", - "createdAt", - "updatedAt", - ], - }) - sorting: ISorting, - @PaginationParams() pagination: IPagination, - @FilterParams() filters?: IFilters, - ): Promise { - const total = await this.ordersService.getTotalCount(filters); - const data = await this.ordersService.findMany({ - pagination, - sorting, - filters, - }); - - return { - data, - meta: { - ...pagination, - total, - }, - }; - } - @Post() @Serializable(OrderEntity) @ApiOperation({ summary: "Creates a new order" }) diff --git a/src/orders/@/orders.service.ts b/src/orders/@/orders.service.ts index e905697..cc95de3 100644 --- a/src/orders/@/orders.service.ts +++ b/src/orders/@/orders.service.ts @@ -5,14 +5,17 @@ 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 { dishes } from "@postgress-db/schema/dishes"; +import { orderDishes } from "@postgress-db/schema/order-dishes"; import { orderNumberBroneering, orders } from "@postgress-db/schema/orders"; import { restaurants } from "@postgress-db/schema/restaurants"; -import { asc, count, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { AnyPgSelect, PgSelectPrepare } from "drizzle-orm/pg-core"; import { PG_CONNECTION } from "src/constants"; import { GuestsService } from "src/guests/guests.service"; import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; +import { KitchenOrderDishDto } from "src/orders/@/dtos/kitchen-order-dish.dto"; import { OrderEntity } from "src/orders/@/entities/order.entity"; @Injectable() @@ -112,6 +115,7 @@ export class OrdersService { }) .returning(); + // @ts-expect-errorsafas return order; } @@ -129,43 +133,6 @@ export class OrdersService { return await query.then((res) => res[0].value); } - private async attachRestaurantNames( - orders: OrderEntity[], - ): Promise { - // Get unique restaurant IDs from orders - const restaurantIds = [ - ...new Set( - orders.filter((o) => o.restaurantId).map((o) => o.restaurantId), - ), - ].filter(Boolean) as string[]; - - if (restaurantIds.length === 0) { - return orders; - } - - // Fetch all relevant restaurants in one query - const restaurantsResult = await this.pg - .select({ - id: restaurants.id, - name: restaurants.name, - }) - .from(restaurants) - .where(inArray(restaurants.id, restaurantIds)); - - // Create a map for quick lookups - const restaurantMap = new Map( - restaurantsResult.map((restaurant) => [restaurant.id, restaurant.name]), - ); - - // Attach restaurant names to orders - return orders.map((order) => ({ - ...order, - restaurantName: order.restaurantId - ? restaurantMap.get(order.restaurantId) ?? null - : null, - })); - } - public async findMany(options?: { pagination?: IPagination; sorting?: ISorting; @@ -175,6 +142,7 @@ export class OrdersService { const query = this.pg .select({ + // Order fields id: orders.id, number: orders.number, tableNumber: orders.tableNumber, @@ -193,44 +161,93 @@ export class OrdersService { total: orders.total, isHiddenForGuest: orders.isHiddenForGuest, isRemoved: orders.isRemoved, + isArchived: orders.isArchived, createdAt: orders.createdAt, updatedAt: orders.updatedAt, removedAt: orders.removedAt, delayedTo: orders.delayedTo, restaurantId: orders.restaurantId, guestId: orders.guestId, - }) - .from(orders); - if (filters) { - query.where(DrizzleUtils.buildFilterConditions(orders, filters)); - } - - if (sorting) { - query.orderBy( - sorting.sortOrder === "asc" - ? asc(sql.identifier(sorting.sortBy)) - : desc(sql.identifier(sorting.sortBy)), - ); - } - - const _orders = await query + // Restaurant name + restaurantName: restaurants.name, + }) + .from(orders) + .where(and(eq(orders.isArchived, false), eq(orders.isRemoved, false))) + .leftJoin(restaurants, eq(orders.restaurantId, restaurants.id)) + .leftJoin(orderDishes, eq(orders.id, orderDishes.orderId)) + .groupBy(orders.id, restaurants.name); + + // if (filters) { + // query.where(DrizzleUtils.buildFilterConditions(orders, filters)); + // } + + query.orderBy(desc(orders.createdAt)); + // if (sorting) { + // query.orderBy( + // sorting.sortOrder === "asc" + // ? asc(sql.identifier(sorting.sortBy)) + // : desc(sql.identifier(sorting.sortBy)), + // ); + // } + + const results = await query .limit(pagination?.size ?? 10) .offset(pagination?.offset ?? 0); - return this.attachRestaurantNames(_orders); + return results.map((order) => ({ + ...order, + orderDishes: [], + // createdAt: new Date(order.createdAt), + })); } public async findById(id: string): Promise { - const result = await this.findByIdQuery.execute({ id }); + const [result] = await this.pg + .select({ + // Order fields + id: orders.id, + number: orders.number, + tableNumber: orders.tableNumber, + type: orders.type, + status: orders.status, + currency: orders.currency, + from: orders.from, + note: orders.note, + guestName: orders.guestName, + guestPhone: orders.guestPhone, + guestsAmount: orders.guestsAmount, + subtotal: orders.subtotal, + discountAmount: orders.discountAmount, + surchargeAmount: orders.surchargeAmount, + bonusUsed: orders.bonusUsed, + total: orders.total, + isHiddenForGuest: orders.isHiddenForGuest, + isRemoved: orders.isRemoved, + isArchived: orders.isArchived, + createdAt: orders.createdAt, + updatedAt: orders.updatedAt, + removedAt: orders.removedAt, + delayedTo: orders.delayedTo, + restaurantId: orders.restaurantId, + guestId: orders.guestId, - const order = result[0]; + // Restaurant name + restaurantName: restaurants.name, + }) + .from(orders) + .leftJoin(restaurants, eq(orders.restaurantId, restaurants.id)) + .leftJoin(orderDishes, eq(orders.id, orderDishes.orderId)) + .where(eq(orders.id, id)) + .groupBy(orders.id, restaurants.name); - if (!order) { + if (!result) { throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); } - const [orderWithRestaurant] = await this.attachRestaurantNames([order]); - return orderWithRestaurant; + return { + ...result, + orderDishes: [], + }; } } diff --git a/src/orders/dispatcher/dispatcher-orders.controller.ts b/src/orders/dispatcher/dispatcher-orders.controller.ts new file mode 100644 index 0000000..a6ce57a --- /dev/null +++ b/src/orders/dispatcher/dispatcher-orders.controller.ts @@ -0,0 +1,41 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { CursorParams, ICursor } from "@core/decorators/cursor.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Get } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +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, + ) {} + + @Get() + @Serializable(DispatcherOrdersPaginatedEntity) + @ApiOperation({ + summary: "Gets orders for dispatcher", + }) + @ApiOkResponse({ + description: "Orders have been successfully fetched", + type: DispatcherOrdersPaginatedEntity, + }) + async findMany( + @CursorParams() cursor: ICursor, + ): Promise { + const data = await this.dispatcherOrdersService.findMany({ cursor }); + + 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..136adf1 --- /dev/null +++ b/src/orders/dispatcher/dispatcher-orders.service.ts @@ -0,0 +1,55 @@ +import { ICursor } from "@core/decorators/cursor.decorator"; +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 { DispatcherOrderEntity } from "src/orders/dispatcher/entities/dispatcher-order.entity"; + +@Injectable() +export class DispatcherOrdersService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + async findMany(options?: { + cursor?: ICursor; + }): Promise { + const { cursor } = options ?? {}; + + const fetchedOrders = await this.pg.query.orders.findMany({ + where: (orders, { eq, and, lt }) => + and( + eq(orders.isArchived, false), + eq(orders.isRemoved, false), + cursor?.cursorId + ? lt(orders.createdAt, new Date(cursor.cursorId)) + : undefined, + ), + with: { + // Restaurant for restaurantName + restaurant: { + columns: { + name: true, + }, + }, + // Order dishes for statuses + orderDishes: { + columns: { + status: true, + }, + }, + }, + orderBy: (orders, { asc, desc }) => [ + desc(orders.createdAt), + asc(orders.id), + ], + limit: cursor?.limit ?? 100, + }); + + return fetchedOrders.map((order) => ({ + ...order, + restaurantName: order.restaurant?.name ?? null, + })); + } +} 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/orders.module.ts b/src/orders/orders.module.ts index d24b303..47811e6 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -3,11 +3,13 @@ import { DrizzleModule } from "@postgress-db/drizzle.module"; import { GuestsModule } from "src/guests/guests.module"; import { OrdersController } from "src/orders/@/orders.controller"; import { OrdersService } from "src/orders/@/orders.service"; +import { DispatcherOrdersController } from "src/orders/dispatcher/dispatcher-orders.controller"; +import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders.service"; @Module({ imports: [DrizzleModule, GuestsModule], - providers: [OrdersService], - controllers: [OrdersController], + providers: [OrdersService, DispatcherOrdersService], + controllers: [OrdersController, DispatcherOrdersController], exports: [OrdersService], }) export class OrdersModule {} diff --git a/test/helpers/database/index.ts b/test/helpers/database/index.ts index dbdd4ab..a0e292b 100644 --- a/test/helpers/database/index.ts +++ b/test/helpers/database/index.ts @@ -13,6 +13,7 @@ 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"; @@ -30,6 +31,7 @@ export const schema = { ...manyToMany, ...orderDeliveries, ...orderDishes, + ...orders, ...files, }; diff --git a/test/helpers/seed/cache.json b/test/helpers/seed/cache.json index 676ee62..aa31f10 100644 --- a/test/helpers/seed/cache.json +++ b/test/helpers/seed/cache.json @@ -89,6 +89,24 @@ "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 index 8331cb7..8f8df81 100644 --- a/test/helpers/seed/index.ts +++ b/test/helpers/seed/index.ts @@ -1,11 +1,20 @@ 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; + }; } type SeedVariant = "mini"; @@ -14,6 +23,12 @@ const variants: Record = { mini: { workers: 50, restaurants: 10, + dishes: 10, + orders: { + active: 10_000, + removed: 10_000, + archived: 80_000, + }, }, }; @@ -24,6 +39,8 @@ async function seed(variant: SeedVariantData) { 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..3201c29 --- /dev/null +++ b/test/helpers/seed/orders.ts @@ -0,0 +1,184 @@ +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, +}: { + active: number; + archived: number; + removed: number; +}) { + console.log("Seeding orders..."); + + const totalCount = active + archived + removed; + const orders = await mockOrders(totalCount); + + orders.forEach((order, index) => { + if (index < active) { + return; + } + if (index < 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)); From 90d31a6ba72490551053cafbfe7e1e6fd8d094f8 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 22 Jan 2025 18:55:00 +0200 Subject: [PATCH 053/180] feat: attention required orders endpoint for dispatcher --- .../dispatcher-orders.controller.ts | 16 +++++ .../dispatcher/dispatcher-orders.service.ts | 72 +++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/orders/dispatcher/dispatcher-orders.controller.ts b/src/orders/dispatcher/dispatcher-orders.controller.ts index a6ce57a..480514e 100644 --- a/src/orders/dispatcher/dispatcher-orders.controller.ts +++ b/src/orders/dispatcher/dispatcher-orders.controller.ts @@ -38,4 +38,20 @@ export class DispatcherOrdersController { }, }; } + + @Get("attention-required") + @Serializable(DispatcherOrdersPaginatedEntity) + async findManyAttentionRequired(): Promise { + const data = await this.dispatcherOrdersService.findManyAttentionRequired(); + + 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 index 136adf1..f690639 100644 --- a/src/orders/dispatcher/dispatcher-orders.service.ts +++ b/src/orders/dispatcher/dispatcher-orders.service.ts @@ -1,6 +1,7 @@ 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 { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; import { DispatcherOrderEntity } from "src/orders/dispatcher/entities/dispatcher-order.entity"; @@ -12,6 +13,15 @@ export class DispatcherOrdersService { 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; }): Promise { @@ -47,9 +57,63 @@ export class DispatcherOrdersService { limit: cursor?.limit ?? 100, }); - return fetchedOrders.map((order) => ({ - ...order, - restaurantName: order.restaurant?.name ?? null, - })); + return this.attachRestaurantsName(fetchedOrders); + } + + async findManyAttentionRequired() { + const fetchedOrders = await this.pg.query.orders.findMany({ + where: ( + orders, + { eq, and, lt, or, isNotNull, isNull, notInArray, exists }, + ) => + and( + // Check if the order is delayed and the delay time is in the past + or( + and(isNotNull(orders.delayedTo), lt(orders.delayedTo, new Date())), + 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"), + ), + ), + ), + ), + // Exclude archived orders + eq(orders.isArchived, false), + // Exclude cancelled and completed orders + notInArray(orders.status, ["cancelled", "completed"]), + ), + with: { + // Restaurant for restaurantName + restaurant: { + columns: { + name: true, + }, + }, + // Order dishes for statuses + orderDishes: { + columns: { + status: true, + }, + }, + }, + orderBy: (orders, { asc, desc }) => [ + desc(orders.createdAt), + asc(orders.id), + ], + limit: 100, + }); + + return this.attachRestaurantsName(fetchedOrders); } } From 0cecd71aa3ec3c0028b3f396a379a4c481962795 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 23 Jan 2025 10:52:07 +0200 Subject: [PATCH 054/180] feat: delayed orders endpoint --- .../dispatcher-orders.controller.ts | 16 ++++++ .../dispatcher/dispatcher-orders.service.ts | 52 +++++++++++++++---- test/helpers/seed/index.ts | 2 + test/helpers/seed/orders.ts | 16 ++++-- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/orders/dispatcher/dispatcher-orders.controller.ts b/src/orders/dispatcher/dispatcher-orders.controller.ts index 480514e..477aced 100644 --- a/src/orders/dispatcher/dispatcher-orders.controller.ts +++ b/src/orders/dispatcher/dispatcher-orders.controller.ts @@ -54,4 +54,20 @@ export class DispatcherOrdersController { }, }; } + + @Get("delayed") + @Serializable(DispatcherOrdersPaginatedEntity) + async findManyDelayed(): Promise { + const data = await this.dispatcherOrdersService.findManyDelayed(); + + 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 index f690639..2c1840a 100644 --- a/src/orders/dispatcher/dispatcher-orders.service.ts +++ b/src/orders/dispatcher/dispatcher-orders.service.ts @@ -28,10 +28,18 @@ export class DispatcherOrdersService { const { cursor } = options ?? {}; const fetchedOrders = await this.pg.query.orders.findMany({ - where: (orders, { eq, and, lt }) => + 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, new Date())), + isNull(orders.delayedTo), + ), + // Cursor pagination cursor?.cursorId ? lt(orders.createdAt, new Date(cursor.cursorId)) : undefined, @@ -50,10 +58,7 @@ export class DispatcherOrdersService { }, }, }, - orderBy: (orders, { asc, desc }) => [ - desc(orders.createdAt), - asc(orders.id), - ], + orderBy: (orders, { desc }) => [desc(orders.createdAt)], limit: cursor?.limit ?? 100, }); @@ -107,10 +112,39 @@ export class DispatcherOrdersService { }, }, }, - orderBy: (orders, { asc, desc }) => [ - desc(orders.createdAt), - asc(orders.id), - ], + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + limit: 100, + }); + + return this.attachRestaurantsName(fetchedOrders); + } + + async findManyDelayed() { + const fetchedOrders = await this.pg.query.orders.findMany({ + where: (orders, { eq, and, gt, isNotNull }) => + and( + // Exclude archived orders + eq(orders.isArchived, false), + // Exclude removed orders + eq(orders.isRemoved, false), + // Delayed orders condition + and(isNotNull(orders.delayedTo), gt(orders.delayedTo, new Date())), + ), + with: { + // Restaurant for restaurantName + restaurant: { + columns: { + name: true, + }, + }, + // Order dishes for statuses + orderDishes: { + columns: { + status: true, + }, + }, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], limit: 100, }); diff --git a/test/helpers/seed/index.ts b/test/helpers/seed/index.ts index 8f8df81..6e87509 100644 --- a/test/helpers/seed/index.ts +++ b/test/helpers/seed/index.ts @@ -14,6 +14,7 @@ interface SeedVariantData { active: number; archived: number; removed: number; + delayed: number; }; } @@ -28,6 +29,7 @@ const variants: Record = { active: 10_000, removed: 10_000, archived: 80_000, + delayed: 1_000, }, }, }; diff --git a/test/helpers/seed/orders.ts b/test/helpers/seed/orders.ts index 3201c29..c25da9e 100644 --- a/test/helpers/seed/orders.ts +++ b/test/helpers/seed/orders.ts @@ -137,21 +137,31 @@ 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; + const totalCount = active + archived + removed + delayed; const orders = await mockOrders(totalCount); orders.forEach((order, index) => { - if (index < active) { + if (index < delayed) { + order.order.status = "pending"; + order.order.delayedTo = faker.date.future(); + order.orderDishes.forEach((dish) => { + dish.status = "pending"; + }); return; } - if (index < active + archived) { + if (index < delayed + active) { + return; + } + if (index < delayed + active + archived) { order.order.isArchived = true; order.order.removedAt = null; } else { From c1853ac64327a10d2618ec687ade69541a2532e6 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 23 Jan 2025 16:21:54 +0200 Subject: [PATCH 055/180] feat: some fixes and features --- src/@core/decorators/is-phone.decorator.ts | 27 +++++++--- src/i18n/validators/index.ts | 4 ++ src/orders/@/entities/order.entity.ts | 7 ++- .../dispatcher-orders.controller.ts | 52 ++++++++++++++++--- .../dispatcher/dispatcher-orders.service.ts | 51 +++++++++++++++--- 5 files changed, 119 insertions(+), 22 deletions(-) diff --git a/src/@core/decorators/is-phone.decorator.ts b/src/@core/decorators/is-phone.decorator.ts index e04b32f..f1e41e2 100644 --- a/src/@core/decorators/is-phone.decorator.ts +++ b/src/@core/decorators/is-phone.decorator.ts @@ -1,11 +1,12 @@ -import { - registerDecorator, - ValidationArguments, - ValidationOptions, -} from "@i18n-class-validator"; +import { registerDecorator, ValidationOptions } from "@i18n-class-validator"; import { isValidPhoneNumber } from "libphonenumber-js"; +import { I18nContext } from "nestjs-i18n"; -export function IsPhoneNumber(validationOptions?: ValidationOptions) { +export function IsPhoneNumber( + validationOptions?: ValidationOptions & { + isOptional?: boolean; + }, +) { return function (object: object, propertyName: string) { registerDecorator({ name: "isPhoneNumber", @@ -14,10 +15,20 @@ export function IsPhoneNumber(validationOptions?: ValidationOptions) { options: validationOptions, validator: { validate(value: any) { + if ( + validationOptions?.isOptional && + (value === undefined || value === null || value === "") + ) { + return true; + } + return typeof value === "string" && isValidPhoneNumber(value); }, - defaultMessage(args: ValidationArguments) { - return `${args.property} must be a valid phone number`; + defaultMessage() { + const i18n = I18nContext.current(); + const errorText = i18n?.t("validation.validators.isPhoneNumber"); + + return `${errorText}`; }, }, }); diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts index 91c9de4..f029237 100644 --- a/src/i18n/validators/index.ts +++ b/src/i18n/validators/index.ts @@ -14,6 +14,7 @@ import { IsString as _IsString, IsStrongPassword as _IsStrongPassword, IsUUID as _IsUUID, + Min as _Min, MinLength as _MinLength, IsNumberOptions, ValidationOptions, @@ -140,3 +141,6 @@ export const IsDecimal = ( applyDecorators( _IsDecimal(options, mergeI18nValidation("isDecimal", validationOptions)), ); + +export const Min = (min: number, validationOptions?: ValidationOptions) => + applyDecorators(_Min(min, mergeI18nValidation("min", validationOptions))); diff --git a/src/orders/@/entities/order.entity.ts b/src/orders/@/entities/order.entity.ts index 0aa846a..3188eb8 100644 --- a/src/orders/@/entities/order.entity.ts +++ b/src/orders/@/entities/order.entity.ts @@ -1,3 +1,4 @@ +import { IsPhoneNumber } from "@core/decorators/is-phone.decorator"; import { IsBoolean, IsDate, @@ -8,6 +9,7 @@ import { IsOptional, IsString, IsUUID, + Min, } from "@i18n-class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ZodCurrency } from "@postgress-db/schema/general"; @@ -127,8 +129,10 @@ export class OrderEntity implements IOrder { }) guestName: string | null; - @IsString() @IsOptional() + @IsPhoneNumber({ + isOptional: true, + }) @Expose() @ApiPropertyOptional({ description: "Guest phone number", @@ -137,6 +141,7 @@ export class OrderEntity implements IOrder { guestPhone: string | null; @IsInt() + @Min(1) @IsOptional() @Expose() @ApiPropertyOptional({ diff --git a/src/orders/dispatcher/dispatcher-orders.controller.ts b/src/orders/dispatcher/dispatcher-orders.controller.ts index 477aced..0d24a6a 100644 --- a/src/orders/dispatcher/dispatcher-orders.controller.ts +++ b/src/orders/dispatcher/dispatcher-orders.controller.ts @@ -1,8 +1,9 @@ import { Controller } from "@core/decorators/controller.decorator"; import { CursorParams, ICursor } from "@core/decorators/cursor.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; -import { Get } from "@nestjs/common"; +import { Get, Query } from "@nestjs/common"; import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { OrderTypeEnum } from "@postgress-db/schema/orders"; import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders.service"; import { DispatcherOrdersPaginatedEntity } from "src/orders/dispatcher/entities/dispatcher-orders-paginated.entity"; @@ -25,8 +26,15 @@ export class DispatcherOrdersController { }) async findMany( @CursorParams() cursor: ICursor, + @Query("type") type?: string, ): Promise { - const data = await this.dispatcherOrdersService.findMany({ cursor }); + const data = await this.dispatcherOrdersService.findMany({ + cursor, + type: + type !== "undefined" && type !== "all" + ? (type as OrderTypeEnum) + : undefined, + }); return { data, @@ -41,8 +49,24 @@ export class DispatcherOrdersController { @Get("attention-required") @Serializable(DispatcherOrdersPaginatedEntity) - async findManyAttentionRequired(): Promise { - const data = await this.dispatcherOrdersService.findManyAttentionRequired(); + @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, @@ -57,8 +81,24 @@ export class DispatcherOrdersController { @Get("delayed") @Serializable(DispatcherOrdersPaginatedEntity) - async findManyDelayed(): Promise { - const data = await this.dispatcherOrdersService.findManyDelayed(); + @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, diff --git a/src/orders/dispatcher/dispatcher-orders.service.ts b/src/orders/dispatcher/dispatcher-orders.service.ts index 2c1840a..e8dfd30 100644 --- a/src/orders/dispatcher/dispatcher-orders.service.ts +++ b/src/orders/dispatcher/dispatcher-orders.service.ts @@ -2,6 +2,8 @@ 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"; @@ -24,8 +26,10 @@ export class DispatcherOrdersService { async findMany(options?: { cursor?: ICursor; + type?: OrderTypeEnum; + restaurantId?: string; }): Promise { - const { cursor } = options ?? {}; + const { cursor, type, restaurantId } = options ?? {}; const fetchedOrders = await this.pg.query.orders.findMany({ where: (orders, { eq, and, lt, isNull, or, isNotNull }) => @@ -36,13 +40,20 @@ export class DispatcherOrdersService { eq(orders.isRemoved, false), // Exclude pending delayed orders or( - and(isNotNull(orders.delayedTo), lt(orders.delayedTo, new Date())), + 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 @@ -65,16 +76,28 @@ export class DispatcherOrdersService { return this.attachRestaurantsName(fetchedOrders); } - async findManyAttentionRequired() { + 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( - and(isNotNull(orders.delayedTo), lt(orders.delayedTo, new Date())), + // 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( @@ -97,6 +120,8 @@ export class DispatcherOrdersService { 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 @@ -119,16 +144,28 @@ export class DispatcherOrdersService { return this.attachRestaurantsName(fetchedOrders); } - async findManyDelayed() { + 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, new Date())), + and( + isNotNull(orders.delayedTo), + gt(orders.delayedTo, addDays(new Date(), 1)), + ), + // Filter by type + !!type ? eq(orders.type, type) : undefined, ), with: { // Restaurant for restaurantName @@ -144,7 +181,7 @@ export class DispatcherOrdersService { }, }, }, - orderBy: (orders, { desc }) => [desc(orders.createdAt)], + orderBy: (orders, { asc }) => [asc(orders.delayedTo)], limit: 100, }); From 2d7a9d4051ff47166af4d5c6d0fa5b61dc9e7328 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 27 Jan 2025 13:35:43 +0200 Subject: [PATCH 056/180] feat: some order creation validation --- src/i18n/messages/en/errors.json | 4 + src/i18n/messages/et/errors.json | 4 + src/i18n/messages/ru/errors.json | 4 + src/orders/@/orders.controller.ts | 8 -- src/orders/@/orders.service.ts | 207 ++++++++---------------------- 5 files changed, 65 insertions(+), 162 deletions(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 561ca12..6b5e19b 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -62,5 +62,9 @@ "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" + }, + "orders": { + "table-number-is-required": "Table number is required", + "phone-number-is-required": "Phone number is required" } } diff --git a/src/i18n/messages/et/errors.json b/src/i18n/messages/et/errors.json index fe4cbba..2d8584e 100644 --- a/src/i18n/messages/et/errors.json +++ b/src/i18n/messages/et/errors.json @@ -57,5 +57,9 @@ "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/ru/errors.json b/src/i18n/messages/ru/errors.json index 4c57bb6..bf643cb 100644 --- a/src/i18n/messages/ru/errors.json +++ b/src/i18n/messages/ru/errors.json @@ -60,5 +60,9 @@ "dish-categories": { "failed-to-create-dish-category": "Не удалось создать категорию блюд", "with-this-id-doesnt-exist": "Категория блюд с таким id не существует" + }, + "orders": { + "table-number-is-required": "Необходимо указать номер стола", + "phone-number-is-required": "Необходимо указать номер телефона" } } diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 65cfe8b..1475f21 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -1,11 +1,5 @@ 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 { Body, Get, Param, Post } from "@nestjs/common"; import { @@ -19,8 +13,6 @@ import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; import { OrderEntity } from "src/orders/@/entities/order.entity"; import { OrdersService } from "src/orders/@/orders.service"; -import { OrdersPaginatedDto } from "./dtos/orders-paginated.dto"; - @Controller("orders") export class OrdersController { constructor(private readonly ordersService: OrdersService) {} diff --git a/src/orders/@/orders.service.ts b/src/orders/@/orders.service.ts index cc95de3..d1b46b7 100644 --- a/src/orders/@/orders.service.ts +++ b/src/orders/@/orders.service.ts @@ -1,64 +1,25 @@ import { IFilters } from "@core/decorators/filter.decorator"; -import { IPagination } 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 { Inject, Injectable } from "@nestjs/common"; import { DrizzleUtils } from "@postgress-db/drizzle-utils"; import { Schema } from "@postgress-db/drizzle.module"; -import { dishes } from "@postgress-db/schema/dishes"; -import { orderDishes } from "@postgress-db/schema/order-dishes"; import { orderNumberBroneering, orders } from "@postgress-db/schema/orders"; -import { restaurants } from "@postgress-db/schema/restaurants"; -import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"; +import { count, desc } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { AnyPgSelect, PgSelectPrepare } from "drizzle-orm/pg-core"; import { PG_CONNECTION } from "src/constants"; import { GuestsService } from "src/guests/guests.service"; import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; -import { KitchenOrderDishDto } from "src/orders/@/dtos/kitchen-order-dish.dto"; +import { UpdateOrderDto } from "src/orders/@/dtos/update-order.dto"; import { OrderEntity } from "src/orders/@/entities/order.entity"; @Injectable() export class OrdersService { - private readonly findByIdQuery: PgSelectPrepare; - constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, private readonly guestsService: GuestsService, - ) { - this.findByIdQuery = this.pg - .select({ - id: orders.id, - number: orders.number, - tableNumber: orders.tableNumber, - type: orders.type, - status: orders.status, - currency: orders.currency, - from: orders.from, - note: orders.note, - guestName: orders.guestName, - guestPhone: orders.guestPhone, - guestsAmount: orders.guestsAmount, - subtotal: orders.subtotal, - discountAmount: orders.discountAmount, - surchargeAmount: orders.surchargeAmount, - bonusUsed: orders.bonusUsed, - total: orders.total, - isHiddenForGuest: orders.isHiddenForGuest, - isRemoved: orders.isRemoved, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - removedAt: orders.removedAt, - delayedTo: orders.delayedTo, - restaurantId: orders.restaurantId, - guestId: orders.guestId, - }) - .from(orders) - .where(eq(orders.id, sql.placeholder("id"))) - .limit(1) - .prepare("find_order_by_id"); - } + ) {} private async generateOrderNumber() { // get last broneering @@ -79,7 +40,33 @@ export class OrdersService { return number; } + public async checkDto(dto: UpdateOrderDto) { + // Table number is required for banquet and hall + if ( + (!dto.tableNumber || dto.tableNumber === "") && + (dto.type === "banquet" || dto.type === "hall") + ) { + throw new BadRequestException("errors.orders.table-number-is-required", { + property: "tableNumber", + }); + } + + // 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", + }); + } + } + async create(dto: CreateOrderDto): Promise { + await this.checkDto(dto); + const { type, guestName, @@ -94,7 +81,7 @@ export class OrdersService { const number = await this.generateOrderNumber(); const guest = await this.guestsService.findByPhoneNumber(guestPhone); - const [order] = await this.pg + const [createdOrder] = await this.pg .insert(orders) .values({ number, @@ -113,10 +100,11 @@ export class OrdersService { guestName: guestName ?? guest?.name, guestPhone, }) - .returning(); + .returning({ + id: orders.id, + }); - // @ts-expect-errorsafas - return order; + return this.findById(createdOrder.id); } public async getTotalCount(filters?: IFilters): Promise { @@ -133,121 +121,32 @@ export class OrdersService { 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({ - // Order fields - id: orders.id, - number: orders.number, - tableNumber: orders.tableNumber, - type: orders.type, - status: orders.status, - currency: orders.currency, - from: orders.from, - note: orders.note, - guestName: orders.guestName, - guestPhone: orders.guestPhone, - guestsAmount: orders.guestsAmount, - subtotal: orders.subtotal, - discountAmount: orders.discountAmount, - surchargeAmount: orders.surchargeAmount, - bonusUsed: orders.bonusUsed, - total: orders.total, - isHiddenForGuest: orders.isHiddenForGuest, - isRemoved: orders.isRemoved, - isArchived: orders.isArchived, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - removedAt: orders.removedAt, - delayedTo: orders.delayedTo, - restaurantId: orders.restaurantId, - guestId: orders.guestId, - - // Restaurant name - restaurantName: restaurants.name, - }) - .from(orders) - .where(and(eq(orders.isArchived, false), eq(orders.isRemoved, false))) - .leftJoin(restaurants, eq(orders.restaurantId, restaurants.id)) - .leftJoin(orderDishes, eq(orders.id, orderDishes.orderId)) - .groupBy(orders.id, restaurants.name); - - // if (filters) { - // query.where(DrizzleUtils.buildFilterConditions(orders, filters)); - // } - - query.orderBy(desc(orders.createdAt)); - // if (sorting) { - // query.orderBy( - // sorting.sortOrder === "asc" - // ? asc(sql.identifier(sorting.sortBy)) - // : desc(sql.identifier(sorting.sortBy)), - // ); - // } - - const results = await query - .limit(pagination?.size ?? 10) - .offset(pagination?.offset ?? 0); - - return results.map((order) => ({ + public attachRestaurantsName< + T extends { restaurant?: { name?: string | null } | null }, + >(orders: Array): Array { + return orders.map((order) => ({ ...order, - orderDishes: [], - // createdAt: new Date(order.createdAt), + restaurantName: order.restaurant?.name ?? null, })); } public async findById(id: string): Promise { - const [result] = await this.pg - .select({ - // Order fields - id: orders.id, - number: orders.number, - tableNumber: orders.tableNumber, - type: orders.type, - status: orders.status, - currency: orders.currency, - from: orders.from, - note: orders.note, - guestName: orders.guestName, - guestPhone: orders.guestPhone, - guestsAmount: orders.guestsAmount, - subtotal: orders.subtotal, - discountAmount: orders.discountAmount, - surchargeAmount: orders.surchargeAmount, - bonusUsed: orders.bonusUsed, - total: orders.total, - isHiddenForGuest: orders.isHiddenForGuest, - isRemoved: orders.isRemoved, - isArchived: orders.isArchived, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - removedAt: orders.removedAt, - delayedTo: orders.delayedTo, - restaurantId: orders.restaurantId, - guestId: orders.guestId, - - // Restaurant name - restaurantName: restaurants.name, - }) - .from(orders) - .leftJoin(restaurants, eq(orders.restaurantId, restaurants.id)) - .leftJoin(orderDishes, eq(orders.id, orderDishes.orderId)) - .where(eq(orders.id, id)) - .groupBy(orders.id, restaurants.name); + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq }) => eq(orders.id, id), + with: { + restaurant: { + columns: { + name: true, + }, + }, + orderDishes: true, + }, + }); - if (!result) { + if (!order) { throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); } - return { - ...result, - orderDishes: [], - }; + return this.attachRestaurantsName([order])[0]; } } From 9b8fb5aff44eec055b930cc5fdd3c57996e12c19 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 27 Jan 2025 14:31:59 +0200 Subject: [PATCH 057/180] feat: add order dish enpoint and service --- src/orders/@/dtos/add-order-dish.dto.ts | 22 +++++++ src/orders/@/orders.controller.ts | 43 +++++++++--- src/orders/@/services/order-dishes.service.ts | 65 +++++++++++++++++++ src/orders/@/{ => services}/orders.service.ts | 0 src/orders/orders.module.ts | 7 +- 5 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 src/orders/@/dtos/add-order-dish.dto.ts create mode 100644 src/orders/@/services/order-dishes.service.ts rename src/orders/@/{ => services}/orders.service.ts (100%) 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/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 1475f21..4963faf 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -9,13 +9,29 @@ import { ApiOkResponse, ApiOperation, } from "@nestjs/swagger"; +import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; import { OrderEntity } from "src/orders/@/entities/order.entity"; -import { OrdersService } from "src/orders/@/orders.service"; +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) {} + constructor( + private readonly ordersService: OrdersService, + private readonly orderDishesService: OrderDishesService, + ) {} + + @Post() + @Serializable(OrderEntity) + @ApiOperation({ summary: "Creates a new order" }) + @ApiCreatedResponse({ + description: "Order has been successfully created", + type: OrderEntity, + }) + async create(@Body() dto: CreateOrderDto) { + return this.ordersService.create(dto); + } @Get(":id") @Serializable(OrderEntity) @@ -40,14 +56,25 @@ export class OrdersController { return await this.ordersService.findById(id); } - @Post() + @Post(":id/dishes") @Serializable(OrderEntity) - @ApiOperation({ summary: "Creates a new order" }) - @ApiCreatedResponse({ - description: "Order has been successfully created", + @ApiOperation({ summary: "Adds a dish to the order" }) + @ApiOkResponse({ + description: "Dish has been successfully added to the order", type: OrderEntity, }) - async create(@Body() dto: CreateOrderDto) { - return this.ordersService.create(dto); + @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, + ) { + await this.orderDishesService.addDish(orderId, payload); + + return this.ordersService.findById(orderId); } } diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts new file mode 100644 index 0000000..4a48495 --- /dev/null +++ b/src/orders/@/services/order-dishes.service.ts @@ -0,0 +1,65 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { orderDishes } from "@postgress-db/schema/order-dishes"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; + +@Injectable() +export class OrderDishesService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + public async addDish(orderId: string, payload: AddOrderDishDto) { + let isAdditional = false; + + const existing = await this.pg.query.orderDishes.findMany({ + where: (orderDishes, { and, eq, isNull }) => + and( + eq(orderDishes.orderId, orderId), + eq(orderDishes.dishId, payload.dishId), + // Exclude removed + isNull(orderDishes.removedAt), + eq(orderDishes.isRemoved, false), + ), + }); + + // If some with same dishId is ready or completed, then we need to add it as additional + if ( + existing && + existing.length > 0 && + existing.some( + ({ status }) => status === "ready" || status === "completed", + ) + ) { + isAdditional = true; + } + + const dish = await this.pg.query.dishes.findFirst({ + where: (dishes, { eq }) => eq(dishes.id, payload.dishId), + columns: { + name: true, + }, + }); + + const [createdOrderDish] = await this.pg + .insert(orderDishes) + .values({ + orderId, + dishId: payload.dishId, + name: dish?.name ?? "", + status: "pending", + quantity: payload.quantity, + isAdditional, + price: String(0), + finalPrice: String(0), + }) + .returning({ + id: orderDishes.id, + }); + + return createdOrderDish.id; + } +} diff --git a/src/orders/@/orders.service.ts b/src/orders/@/services/orders.service.ts similarity index 100% rename from src/orders/@/orders.service.ts rename to src/orders/@/services/orders.service.ts diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index 47811e6..759a597 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -2,14 +2,15 @@ import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.module"; import { GuestsModule } from "src/guests/guests.module"; import { OrdersController } from "src/orders/@/orders.controller"; -import { OrdersService } from "src/orders/@/orders.service"; +import { OrderDishesService } from "src/orders/@/services/order-dishes.service"; +import { OrdersService } from "src/orders/@/services/orders.service"; import { DispatcherOrdersController } from "src/orders/dispatcher/dispatcher-orders.controller"; import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders.service"; @Module({ imports: [DrizzleModule, GuestsModule], - providers: [OrdersService, DispatcherOrdersService], + providers: [OrdersService, DispatcherOrdersService, OrderDishesService], controllers: [OrdersController, DispatcherOrdersController], - exports: [OrdersService], + exports: [OrdersService, OrderDishesService], }) export class OrdersModule {} From 5277f1c06f20066f09183739406dcce3436d4187 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 27 Jan 2025 18:24:32 +0200 Subject: [PATCH 058/180] refactor: order dishes service --- src/i18n/messages/en/errors.json | 7 + src/orders/@/orders.controller.ts | 2 +- src/orders/@/services/order-dishes.service.ts | 123 ++++++++++++++---- 3 files changed, 107 insertions(+), 25 deletions(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 6b5e19b..48517b5 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -66,5 +66,12 @@ "orders": { "table-number-is-required": "Table number is required", "phone-number-is-required": "Phone number 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" } } diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 4963faf..01ba126 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -73,7 +73,7 @@ export class OrdersController { @Param("id") orderId: string, @Body() payload: AddOrderDishDto, ) { - await this.orderDishesService.addDish(orderId, payload); + await this.orderDishesService.addToOrder(orderId, payload); return this.ordersService.findById(orderId); } diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 4a48495..7e67bb3 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -1,3 +1,5 @@ +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 { orderDishes } from "@postgress-db/schema/order-dishes"; @@ -12,54 +14,127 @@ export class OrderDishesService { private readonly pg: NodePgDatabase, ) {} - public async addDish(orderId: string, payload: AddOrderDishDto) { - let isAdditional = false; + private async getOrder(orderId: string) { + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq, and }) => + and( + eq(orders.id, orderId), + eq(orders.isRemoved, false), + eq(orders.isArchived, false), + ), + columns: { + restaurantId: true, + }, + }); + + 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 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, payload.dishId), + eq(orderDishes.dishId, dishId), // Exclude removed isNull(orderDishes.removedAt), eq(orderDishes.isRemoved, false), ), + columns: { + status: true, + }, }); - // If some with same dishId is ready or completed, then we need to add it as additional - if ( - existing && - existing.length > 0 && - existing.some( - ({ status }) => status === "ready" || status === "completed", - ) - ) { - isAdditional = true; + if (existing.length === 0) { + return false; } - const dish = await this.pg.query.dishes.findFirst({ - where: (dishes, { eq }) => eq(dishes.id, payload.dishId), - columns: { - name: true, - }, - }); + // 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) { + 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 [createdOrderDish] = await this.pg + const [orderDish] = await this.pg .insert(orderDishes) .values({ orderId, dishId: payload.dishId, - name: dish?.name ?? "", + name: dish.name, status: "pending", - quantity: payload.quantity, + quantity, isAdditional, - price: String(0), - finalPrice: String(0), + price: String(price), + finalPrice: String(price), }) .returning({ id: orderDishes.id, }); - return createdOrderDish.id; + return orderDish; } } From 4ae1bdbb5eb5c07782f94168f9b904ff81576803 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 28 Jan 2025 17:05:12 +0200 Subject: [PATCH 059/180] feat: put order dish api endpoint --- src/i18n/messages/en/errors.json | 4 ++- src/orders/@/dtos/update-order-dish.dto.ts | 14 ++++++++ src/orders/@/orders.controller.ts | 24 +++++++++++++- src/orders/@/services/order-dishes.service.ts | 33 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 src/orders/@/dtos/update-order-dish.dto.ts diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 48517b5..26df1c5 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -72,6 +72,8 @@ "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" + "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" } } 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/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 01ba126..15a76b8 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -1,7 +1,7 @@ import { Controller } from "@core/decorators/controller.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; -import { Body, Get, Param, Post } from "@nestjs/common"; +import { Body, Get, Param, Post, Put } from "@nestjs/common"; import { ApiBadRequestResponse, ApiCreatedResponse, @@ -11,6 +11,7 @@ import { } from "@nestjs/swagger"; import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; +import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.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"; @@ -77,4 +78,25 @@ export class OrdersController { return this.ordersService.findById(orderId); } + + @Put(":id/dishes/: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, + ) { + await this.orderDishesService.update(orderDishId, payload); + + return this.ordersService.findById(orderId); + } } diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 7e67bb3..626696a 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -3,9 +3,11 @@ import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; import { Inject, Injectable } from "@nestjs/common"; import { Schema } from "@postgress-db/drizzle.module"; import { orderDishes } from "@postgress-db/schema/order-dishes"; +import { eq } 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 { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; @Injectable() export class OrderDishesService { @@ -40,6 +42,18 @@ export class OrderDishesService { 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), @@ -137,4 +151,23 @@ export class OrderDishesService { return orderDish; } + + public async update(orderDishId: string, payload: UpdateOrderDishDto) { + const { quantity } = payload; + + const orderDish = await this.getOrderDish(orderDishId); + + if (orderDish.status !== "pending") { + throw new BadRequestException( + "errors.order-dishes.cant-update-not-pending-order-dish", + ); + } + + await this.pg + .update(orderDishes) + .set({ + quantity, + }) + .where(eq(orderDishes.id, orderDishId)); + } } From ec0982253f9297e6f2ed2696ab333604a5c8366c Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 28 Jan 2025 19:11:57 +0200 Subject: [PATCH 060/180] feat: remove dish api ednpoint --- src/i18n/messages/en/errors.json | 5 +++- src/orders/@/orders.controller.ts | 23 +++++++++++++++++-- src/orders/@/services/order-dishes.service.ts | 23 +++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 26df1c5..201ce0e 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -74,6 +74,9 @@ "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-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" } } diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 15a76b8..bff6b47 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -1,7 +1,7 @@ import { Controller } from "@core/decorators/controller.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; -import { Body, Get, Param, Post, Put } from "@nestjs/common"; +import { Body, Delete, Get, Param, Patch, Post } from "@nestjs/common"; import { ApiBadRequestResponse, ApiCreatedResponse, @@ -79,7 +79,7 @@ export class OrdersController { return this.ordersService.findById(orderId); } - @Put(":id/dishes/:orderDishId") + @Patch(":id/dishes/:orderDishId") @Serializable(OrderEntity) @ApiOperation({ summary: "Updates a dish in the order" }) @ApiOkResponse({ @@ -99,4 +99,23 @@ export class OrdersController { return this.ordersService.findById(orderId); } + + @Delete(":id/dishes/: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, + ) { + await this.orderDishesService.remove(orderDishId); + + return this.ordersService.findById(orderId); + } } diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 626696a..c3c9d81 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -155,6 +155,12 @@ export class OrderDishesService { public async update(orderDishId: string, payload: UpdateOrderDishDto) { 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") { @@ -163,6 +169,10 @@ export class OrderDishesService { ); } + if (orderDish.isRemoved) { + throw new BadRequestException("errors.order-dishes.is-removed"); + } + await this.pg .update(orderDishes) .set({ @@ -170,4 +180,17 @@ export class OrderDishesService { }) .where(eq(orderDishes.id, orderDishId)); } + + public async remove(orderDishId: string) { + const orderDish = await this.getOrderDish(orderDishId); + + if (orderDish.isRemoved) { + throw new BadRequestException("errors.order-dishes.already-removed"); + } + + await this.pg + .update(orderDishes) + .set({ isRemoved: true, removedAt: new Date() }) + .where(eq(orderDishes.id, orderDishId)); + } } From e1a043ea2857f9522897cf7d0f9f89c8c7bc01be Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 30 Jan 2025 18:10:01 +0200 Subject: [PATCH 061/180] feat: contry code and currency for restaurants --- src/@base/drizzle/schema/restaurants.ts | 8 +++++++- src/i18n/messages/ru/errors.json | 12 ++++++++++++ .../@/entities/restaurant.entity.ts | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index c89d535..27ed863 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -11,7 +11,7 @@ import { uuid, } from "drizzle-orm/pg-core"; -import { dayOfWeekEnum } from "./general"; +import { currencyEnum, dayOfWeekEnum } from "./general"; import { workers } from "./workers"; export const restaurants = pgTable("restaurants", { @@ -32,6 +32,12 @@ export const restaurants = pgTable("restaurants", { // 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("countryCode").notNull().default("EE"), + // Is the restaurant enabled? // isEnabled: boolean("isEnabled").notNull().default(false), diff --git a/src/i18n/messages/ru/errors.json b/src/i18n/messages/ru/errors.json index bf643cb..fd6e825 100644 --- a/src/i18n/messages/ru/errors.json +++ b/src/i18n/messages/ru/errors.json @@ -64,5 +64,17 @@ "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/restaurants/@/entities/restaurant.entity.ts b/src/restaurants/@/entities/restaurant.entity.ts index abc91fa..b287660 100644 --- a/src/restaurants/@/entities/restaurant.entity.ts +++ b/src/restaurants/@/entities/restaurant.entity.ts @@ -1,5 +1,6 @@ import { IsBoolean, + IsEnum, IsISO8601, IsLatitude, IsOptional, @@ -7,6 +8,7 @@ import { IsUUID, } from "@i18n-class-validator"; import { ApiProperty } from "@nestjs/swagger"; +import { ZodCurrency } from "@postgress-db/schema/general"; import { IRestaurant } from "@postgress-db/schema/restaurants"; import { Expose } from "class-transformer"; @@ -71,6 +73,23 @@ export class RestaurantEntity implements IRestaurant { }) 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({ From a7c3626a7c5cf1547d10ebd7bc43de30babeda0f Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 3 Feb 2025 15:44:29 +0200 Subject: [PATCH 062/180] feat: check table number for order creation --- src/i18n/messages/en/errors.json | 4 +- src/orders/@/services/orders.service.ts | 54 +++++++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 201ce0e..91ff618 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -65,7 +65,9 @@ }, "orders": { "table-number-is-required": "Table number is required", - "phone-number-is-required": "Phone 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" }, "order-dishes": { "order-not-found": "Order not found", diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index d1b46b7..c1ec574 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -40,15 +40,53 @@ export class OrdersService { return number; } + public async checkTableNumber(restaurantId: string, tableNumber: string) { + const order = await this.pg.query.orders.findFirst({ + where: (orders, { eq, and, inArray }) => + and( + eq(orders.tableNumber, tableNumber), + eq(orders.restaurantId, restaurantId), + inArray(orders.status, ["pending", "cooking", "ready", "paid"]), + ), + columns: { + id: true, + }, + }); + + if (order) { + throw new BadRequestException( + "errors.orders.table-number-is-already-taken", + { + property: "tableNumber", + }, + ); + } + } + public async checkDto(dto: UpdateOrderDto) { - // Table number is required for banquet and hall - if ( - (!dto.tableNumber || dto.tableNumber === "") && - (dto.type === "banquet" || dto.type === "hall") - ) { - throw new BadRequestException("errors.orders.table-number-is-required", { - property: "tableNumber", - }); + 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); + } } // Phone number is required for delivery, takeaway and banquet From c5dcf22156205370d9c032d8befef71c69e5869b Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 3 Feb 2025 16:18:30 +0200 Subject: [PATCH 063/180] feat: patch method for orders --- src/i18n/messages/en/errors.json | 1 + src/orders/@/orders.controller.ts | 16 ++++++++ src/orders/@/services/orders.service.ts | 51 ++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 91ff618..e61583c 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -64,6 +64,7 @@ "with-this-id-doesnt-exist": "Dish category with this id doesn't exist" }, "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", diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index bff6b47..f849c30 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -12,6 +12,7 @@ import { import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.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"; @@ -57,6 +58,21 @@ export class OrdersController { return await this.ordersService.findById(id); } + @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) { + return this.ordersService.update(id, dto); + } + @Post(":id/dishes") @Serializable(OrderEntity) @ApiOperation({ summary: "Adds a dish to the order" }) diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index c1ec574..92c1427 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -5,7 +5,7 @@ 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 } from "drizzle-orm"; +import { count, desc, eq } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; import { GuestsService } from "src/guests/guests.service"; @@ -145,6 +145,55 @@ export class OrdersService { return this.findById(createdOrder.id); } + async update(id: string, dto: UpdateOrderDto): Promise { + await this.checkDto(dto); + + 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, + guestName, + guestsAmount, + type, + } = dto; + + const guest = await this.guestsService.findByPhoneNumber( + guestPhone ?? order.guestPhone, + ); + + const [updatedOrder] = await this.pg + .update(orders) + .set({ + ...(tableNumber ? { tableNumber } : {}), + ...(restaurantId ? { restaurantId } : {}), + ...(delayedTo ? { delayedTo: new Date(delayedTo) } : {}), + ...(note ? { note } : {}), + ...(guest ? { guestId: guest.id } : {}), + ...(guestName ? { guestName } : {}), + ...(guestsAmount ? { guestsAmount } : {}), + ...(type ? { type } : {}), + }) + .where(eq(orders.id, id)) + .returning({ id: orders.id }); + + return this.findById(updatedOrder.id); + } + public async getTotalCount(filters?: IFilters): Promise { const query = this.pg .select({ From b204291b60594bf46dc02d73667b911858f6df75 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 3 Feb 2025 16:29:13 +0200 Subject: [PATCH 064/180] fix: guest name and phone update for orders --- src/orders/@/services/orders.service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 92c1427..de97a3a 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -167,15 +167,21 @@ export class OrdersService { delayedTo, note, guestPhone, - guestName, guestsAmount, type, } = 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.pg .update(orders) .set({ @@ -185,6 +191,7 @@ export class OrdersService { ...(note ? { note } : {}), ...(guest ? { guestId: guest.id } : {}), ...(guestName ? { guestName } : {}), + ...(guestPhone ? { guestPhone } : {}), ...(guestsAmount ? { guestsAmount } : {}), ...(type ? { type } : {}), }) From 8114e2596890f3e079b187038a30a73707cb98b3 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 3 Feb 2025 23:45:04 +0200 Subject: [PATCH 065/180] feat: drizzle model for payment methods --- src/@base/drizzle/schema/payment-methods.ts | 55 +++++++++++++++++++++ src/@base/drizzle/schema/restaurants.ts | 2 + 2 files changed, 57 insertions(+) create mode 100644 src/@base/drizzle/schema/payment-methods.ts diff --git a/src/@base/drizzle/schema/payment-methods.ts b/src/@base/drizzle/schema/payment-methods.ts new file mode 100644 index 0000000..319b2ba --- /dev/null +++ b/src/@base/drizzle/schema/payment-methods.ts @@ -0,0 +1,55 @@ +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("paymentMethodType", [ + "YOO_KASSA", + "CUSTOM", +]); + +export const paymentMethodIconEnum = pgEnum("paymentMethodIcon", [ + "YOO_KASSA", + "CASH", + "CARD", +]); + +export type IPaymentMethodType = + (typeof paymentMethodTypeEnum.enumValues)[number]; + +export const paymentMethods = pgTable("paymentMethods", { + 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("restaurantId"), + + // For YOO_KASSA // + shopId: text("shopId"), + secretKey: text("secretKey"), + + // Boolean fields // + isActive: boolean("isActive").notNull().default(false), + + // Default timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export type IPaymentMethod = typeof paymentMethods.$inferSelect; + +export const paymentMethodRelations = relations(paymentMethods, ({ one }) => ({ + restaurant: one(restaurants, { + fields: [paymentMethods.restaurantId], + references: [restaurants.id], + }), +})); diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 27ed863..aa2edae 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -1,5 +1,6 @@ import { dishesToRestaurants } from "@postgress-db/schema/dishes"; import { orders } from "@postgress-db/schema/orders"; +import { paymentMethods } from "@postgress-db/schema/payment-methods"; import { restaurantWorkshops } from "@postgress-db/schema/restaurant-workshop"; import { relations } from "drizzle-orm"; import { @@ -76,6 +77,7 @@ export const restaurantRelations = relations(restaurants, ({ many }) => ({ workshops: many(restaurantWorkshops), orders: many(orders), dishesToRestaurants: many(dishesToRestaurants), + paymentMethods: many(paymentMethods), })); export const restaurantHourRelations = relations( From e58ac122bc1a18835c2c46cfc3c723e7218d4e9a Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 4 Feb 2025 13:06:41 +0200 Subject: [PATCH 066/180] feat: encryption module --- src/@base/encryption/encryption.module.ts | 9 ++ src/@base/encryption/encryption.service.ts | 140 +++++++++++++++++++++ src/app.module.ts | 2 + 3 files changed, 151 insertions(+) create mode 100644 src/@base/encryption/encryption.module.ts create mode 100644 src/@base/encryption/encryption.service.ts 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/app.module.ts b/src/app.module.ts index 529ba5e..c3a3e9a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { QueryResolver, } from "nestjs-i18n"; import { ZodValidationPipe } from "nestjs-zod"; +import { EncryptionModule } from "src/@base/encryption/encryption.module"; import { S3Module } from "src/@base/s3/s3.module"; import { AddressesModule } from "src/addresses/addresses.module"; import { DishCategoriesModule } from "src/dish-categories/dish-categories.module"; @@ -55,6 +56,7 @@ import { WorkersModule } from "./workers/workers.module"; url: env.REDIS_URL, }, }), + EncryptionModule, TimezonesModule, AuthModule, WorkersModule, From 27e46e5e4c7252a0e0e7fecb086896bc126d3541 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Feb 2025 12:38:52 +0200 Subject: [PATCH 067/180] feat: order price recalculation --- src/orders/@/services/order-dishes.service.ts | 9 ++++ src/orders/@/services/orders.service.ts | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index c3c9d81..ca7d462 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -9,11 +9,14 @@ import { PG_CONNECTION } from "src/constants"; import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; +import { OrdersService } from "./orders.service"; + @Injectable() export class OrderDishesService { constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly ordersService: OrdersService, ) {} private async getOrder(orderId: string) { @@ -149,6 +152,8 @@ export class OrderDishesService { id: orderDishes.id, }); + await this.ordersService.recalculatePrices(orderId); + return orderDish; } @@ -179,6 +184,8 @@ export class OrderDishesService { quantity, }) .where(eq(orderDishes.id, orderDishId)); + + await this.ordersService.recalculatePrices(orderDish.orderId); } public async remove(orderDishId: string) { @@ -192,5 +199,7 @@ export class OrderDishesService { .update(orderDishes) .set({ isRemoved: true, removedAt: new Date() }) .where(eq(orderDishes.id, orderDishId)); + + await this.ordersService.recalculatePrices(orderDish.orderId); } } diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index de97a3a..4cf11c8 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -215,6 +215,48 @@ export class OrdersService { return await query.then((res) => res[0].value); } + public async recalculatePrices(orderId: string) { + const orderDishes = 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: { + price: true, + quantity: true, + finalPrice: true, + surchargeAmount: true, + discountAmount: true, + }, + }); + + const prices = orderDishes.reduce( + (acc, dish) => { + acc.subtotal += Number(dish.price) * Number(dish.quantity); + acc.surchargeAmount += + Number(dish.surchargeAmount) * Number(dish.quantity); + acc.discountAmount += + Number(dish.discountAmount) * Number(dish.quantity); + acc.total += Number(dish.finalPrice) * Number(dish.quantity); + + return acc; + }, + { subtotal: 0, surchargeAmount: 0, discountAmount: 0, total: 0 }, + ); + + await this.pg + .update(orders) + .set({ + subtotal: prices.subtotal.toString(), + surchargeAmount: prices.surchargeAmount.toString(), + discountAmount: prices.discountAmount.toString(), + total: prices.total.toString(), + }) + .where(eq(orders.id, orderId)); + } + public attachRestaurantsName< T extends { restaurant?: { name?: string | null } | null }, >(orders: Array): Array { From be0182f49df1124b806a92ab68251b6d465c51e7 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Feb 2025 17:56:16 +0200 Subject: [PATCH 068/180] feat: bullmq for order recalculation --- package.json | 2 + src/app.module.ts | 9 +- src/orders/@/services/order-dishes.service.ts | 11 +- src/orders/@/services/orders.service.ts | 50 +------- .../@queue/dto/recalculate-prices-job.dto.ts | 6 + src/orders/@queue/index.ts | 5 + src/orders/@queue/orders-queue.module.ts | 18 +++ src/orders/@queue/orders-queue.processor.ts | 99 +++++++++++++++ src/orders/@queue/orders-queue.producer.ts | 43 +++++++ src/orders/orders.module.ts | 3 +- yarn.lock | 114 +++++++++++++++++- 11 files changed, 305 insertions(+), 55 deletions(-) create mode 100644 src/orders/@queue/dto/recalculate-prices-job.dto.ts create mode 100644 src/orders/@queue/index.ts create mode 100644 src/orders/@queue/orders-queue.module.ts create mode 100644 src/orders/@queue/orders-queue.processor.ts create mode 100644 src/orders/@queue/orders-queue.producer.ts diff --git a/package.json b/package.json index 46b7ab9..52609df 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@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", @@ -46,6 +47,7 @@ "@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-parser": "^1.4.6", diff --git a/src/app.module.ts b/src/app.module.ts index c3a3e9a..819dfa1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,6 +3,7 @@ import * as path from "path"; import env from "@core/env"; import { RolesGuard } from "@core/guards/roles.guard"; import { RedisModule } from "@liaoliaots/nestjs-redis"; +import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { APP_GUARD, APP_PIPE } from "@nestjs/core"; @@ -53,7 +54,13 @@ import { WorkersModule } from "./workers/workers.module"; }), RedisModule.forRoot({ config: { - url: env.REDIS_URL, + url: `${env.REDIS_URL}/1`, + }, + }), + BullModule.forRoot({ + prefix: "toite", + connection: { + url: `${env.REDIS_URL}/2`, }, }), EncryptionModule, diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index ca7d462..93641e1 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -8,15 +8,14 @@ import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; - -import { OrdersService } from "./orders.service"; +import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @Injectable() export class OrderDishesService { constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, - private readonly ordersService: OrdersService, + private readonly ordersProducer: OrdersQueueProducer, ) {} private async getOrder(orderId: string) { @@ -152,7 +151,7 @@ export class OrderDishesService { id: orderDishes.id, }); - await this.ordersService.recalculatePrices(orderId); + await this.ordersProducer.recalculatePrices(orderId); return orderDish; } @@ -185,7 +184,7 @@ export class OrderDishesService { }) .where(eq(orderDishes.id, orderDishId)); - await this.ordersService.recalculatePrices(orderDish.orderId); + await this.ordersProducer.recalculatePrices(orderDish.orderId); } public async remove(orderDishId: string) { @@ -200,6 +199,6 @@ export class OrderDishesService { .set({ isRemoved: true, removedAt: new Date() }) .where(eq(orderDishes.id, orderDishId)); - await this.ordersService.recalculatePrices(orderDish.orderId); + await this.ordersProducer.recalculatePrices(orderDish.orderId); } } diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 4cf11c8..5ff78c9 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -142,7 +142,9 @@ export class OrdersService { id: orders.id, }); - return this.findById(createdOrder.id); + const order = await this.findById(createdOrder.id); + // await this.ordersProducer.orderUpdate("create", order); + return order; } async update(id: string, dto: UpdateOrderDto): Promise { @@ -198,7 +200,9 @@ export class OrdersService { .where(eq(orders.id, id)) .returning({ id: orders.id }); - return this.findById(updatedOrder.id); + const updatedOrderEntity = await this.findById(updatedOrder.id); + // await this.ordersProducer.orderUpdate("update", updatedOrderEntity); + return updatedOrderEntity; } public async getTotalCount(filters?: IFilters): Promise { @@ -215,48 +219,6 @@ export class OrdersService { return await query.then((res) => res[0].value); } - public async recalculatePrices(orderId: string) { - const orderDishes = 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: { - price: true, - quantity: true, - finalPrice: true, - surchargeAmount: true, - discountAmount: true, - }, - }); - - const prices = orderDishes.reduce( - (acc, dish) => { - acc.subtotal += Number(dish.price) * Number(dish.quantity); - acc.surchargeAmount += - Number(dish.surchargeAmount) * Number(dish.quantity); - acc.discountAmount += - Number(dish.discountAmount) * Number(dish.quantity); - acc.total += Number(dish.finalPrice) * Number(dish.quantity); - - return acc; - }, - { subtotal: 0, surchargeAmount: 0, discountAmount: 0, total: 0 }, - ); - - await this.pg - .update(orders) - .set({ - subtotal: prices.subtotal.toString(), - surchargeAmount: prices.surchargeAmount.toString(), - discountAmount: prices.discountAmount.toString(), - total: prices.total.toString(), - }) - .where(eq(orders.id, orderId)); - } - public attachRestaurantsName< T extends { restaurant?: { name?: string | null } | null }, >(orders: Array): Array { 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..8360c31 --- /dev/null +++ b/src/orders/@queue/index.ts @@ -0,0 +1,5 @@ +export const ORDERS_QUEUE = "orders"; + +export enum OrderQueueJobName { + 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..48af134 --- /dev/null +++ b/src/orders/@queue/orders-queue.module.ts @@ -0,0 +1,18 @@ +import { BullModule } from "@nestjs/bullmq"; +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.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"; + +@Module({ + imports: [ + DrizzleModule, + BullModule.registerQueue({ + name: ORDERS_QUEUE, + }), + ], + providers: [OrdersQueueProcessor, OrdersQueueProducer], + 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..0f87a8e --- /dev/null +++ b/src/orders/@queue/orders-queue.processor.ts @@ -0,0 +1,99 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Inject, Logger } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { orders } from "@postgress-db/schema/orders"; +import { Job } from "bullmq"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; +import { RecalculatePricesJobDto } from "src/orders/@queue/dto/recalculate-prices-job.dto"; + +@Processor(ORDERS_QUEUE, {}) +export class OrdersQueueProcessor extends WorkerHost { + private readonly logger = new Logger(OrdersQueueProcessor.name); + + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) { + 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; + } + + 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; + + const orderDishes = 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: { + 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.pg + .update(orders) + .set({ + subtotal: prices.subtotal.toString(), + surchargeAmount: prices.surchargeAmount.toString(), + discountAmount: prices.discountAmount.toString(), + total: prices.total.toString(), + }) + .where(eq(orders.id, 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..67c432b --- /dev/null +++ b/src/orders/@queue/orders-queue.producer.ts @@ -0,0 +1,43 @@ +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 { 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 + * This job should be triggered. It's main task to: + * - 1 + * - 2 + */ + public async handleUpdate() {} + + /** + * 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/orders.module.ts b/src/orders/orders.module.ts index 759a597..0c25d6b 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -4,11 +4,12 @@ import { GuestsModule } from "src/guests/guests.module"; import { OrdersController } from "src/orders/@/orders.controller"; import { OrderDishesService } from "src/orders/@/services/order-dishes.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"; @Module({ - imports: [DrizzleModule, GuestsModule], + imports: [DrizzleModule, GuestsModule, OrdersQueueModule], providers: [OrdersService, DispatcherOrdersService, OrderDishesService], controllers: [OrdersController, DispatcherOrdersController], exports: [OrdersService, OrderDishesService], diff --git a/yarn.lock b/yarn.lock index 081d3a4..05a4e08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1572,6 +1572,36 @@ 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" @@ -1582,6 +1612,21 @@ 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" @@ -3323,6 +3368,19 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +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" @@ -3704,6 +3762,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" @@ -3835,6 +3900,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" @@ -5050,7 +5120,7 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== -ioredis@^5.4.2: +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== @@ -5940,6 +6010,11 @@ 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== +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" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" @@ -6179,6 +6254,27 @@ ms@2.1.3, ms@^2.1.1: 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" @@ -6262,7 +6358,7 @@ nestjs-zod@^4.2.0: "@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== @@ -6286,6 +6382,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" @@ -7675,6 +7778,11 @@ tslib@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" @@ -7821,7 +7929,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== From 09b55d8efaf267ead32952720667ab0f34d1e777 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 12:19:59 +0200 Subject: [PATCH 069/180] feat: socket gateway --- package.json | 4 + src/@core/decorators/user-agent.decorator.ts | 1 + src/app.module.ts | 2 + src/socket/socket.gateway.ts | 117 +++++++++++++++++ src/socket/socket.module.ts | 12 ++ src/socket/socket.service.ts | 7 + src/socket/socket.types.ts | 18 +++ yarn.lock | 128 ++++++++++++++++++- 8 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 src/socket/socket.gateway.ts create mode 100644 src/socket/socket.module.ts create mode 100644 src/socket/socket.service.ts create mode 100644 src/socket/socket.types.ts diff --git a/package.json b/package.json index 52609df..4708b2b 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,10 @@ "@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", "@supercharge/request-ip": "^1.2.0", "@vvo/tzdb": "^6.157.0", "argon2": "^0.31.2", @@ -50,6 +52,7 @@ "bullmq": "^5.40.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie": "^1.0.2", "cookie-parser": "^1.4.6", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", @@ -69,6 +72,7 @@ "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "slugify": "^1.6.6", + "socket.io": "^4.8.1", "uuid": "^9.0.1", "zod": "^3.22.4" }, diff --git a/src/@core/decorators/user-agent.decorator.ts b/src/@core/decorators/user-agent.decorator.ts index 485eeb1..0422e79 100644 --- a/src/@core/decorators/user-agent.decorator.ts +++ b/src/@core/decorators/user-agent.decorator.ts @@ -11,6 +11,7 @@ import { Request } from "express"; const UserAgent = createParamDecorator( (data: unknown, ctx: ExecutionContext): string => { const request = ctx.switchToHttp().getRequest(); + return ( request.headers["user-agent"] || (request.headers["User-Agent"] as string) ); diff --git a/src/app.module.ts b/src/app.module.ts index 819dfa1..1234d74 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { AuthModule } from "./auth/auth.module"; import { SessionAuthGuard } from "./auth/guards/session-auth.guard"; import { RestaurantsModule } from "./restaurants/restaurants.module"; import { WorkersModule } from "./workers/workers.module"; +import { SocketModule } from './socket/socket.module'; @Module({ imports: [ @@ -88,6 +89,7 @@ import { WorkersModule } from "./workers/workers.module"; AcceptLanguageResolver, ], }), + SocketModule, ], providers: [ { diff --git a/src/socket/socket.gateway.ts b/src/socket/socket.gateway.ts new file mode 100644 index 0000000..4052783 --- /dev/null +++ b/src/socket/socket.gateway.ts @@ -0,0 +1,117 @@ +import { Logger } from "@nestjs/common"; +import { + OnGatewayConnection, + OnGatewayDisconnect, + WebSocketGateway, + WebSocketServer, +} from "@nestjs/websockets"; +import { parse as parseCookie } from "cookie"; +import { Socket } from "socket.io"; +import { AUTH_COOKIES } from "src/auth/auth.types"; +import { AuthService } from "src/auth/services/auth.service"; + +import { ConnectedClients } from "./socket.types"; + +@WebSocketGateway() +export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + private server: Socket; + private serviceId: string; + private readonly logger = new Logger(SocketGateway.name); + + public readonly connectedClients: ConnectedClients = {}; + public readonly clientIdToWorkerIdMap: Map = new Map(); + + constructor(private readonly authService: AuthService) {} + + private getClientAuthCookie(socket: Socket): string | null { + const cookies = parseCookie(socket.handshake.headers.cookie || ""); + const auth = cookies?.[AUTH_COOKIES.token]; + + return auth ?? null; + } + + private getUserAgent(socket: Socket): string { + const headers = socket.handshake.headers; + + return (headers["user-agent"] || headers["User-Agent"]) as string; + } + + private getClientIp(socket: Socket): string { + return ( + socket.handshake.address ?? + socket.handshake.headers["x-forwarded-for"] ?? + socket.conn.remoteAddress + ); + } + + async handleConnection(socket: Socket): Promise { + try { + const clientId = socket.id; + const signed = this.getClientAuthCookie(socket); + const httpAgent = this.getUserAgent(socket); + const clientIp = this.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"); + } + + if (!this.connectedClients[session.workerId]) { + this.connectedClients[session.workerId] = {}; + } + + this.connectedClients[session.workerId][clientId] = { + clientId, + socket, + session: { + id: session.id, + isActive: session.isActive, + previousId: session.previousId, + }, + worker: { + id: session.workerId, + isBlocked: session.worker.isBlocked, + restaurantId: session.worker.restaurantId, + role: session.worker.role, + }, + }; + + this.clientIdToWorkerIdMap.set(clientId, session.workerId); + + socket.emit("connected", session.worker); + } catch (error) { + this.logger.error(error); + socket.disconnect(true); + } + } + + async handleDisconnect(socket: Socket): Promise { + const clientId = socket.id; + const workerId = this.clientIdToWorkerIdMap.get(clientId); + + if (!workerId) { + return; + } + + delete this.connectedClients[workerId][clientId]; + + this.clientIdToWorkerIdMap.delete(clientId); + } +} diff --git a/src/socket/socket.module.ts b/src/socket/socket.module.ts new file mode 100644 index 0000000..48791e5 --- /dev/null +++ b/src/socket/socket.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "src/auth/auth.module"; +import { SocketGateway } from "src/socket/socket.gateway"; + +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..f4611a8 --- /dev/null +++ b/src/socket/socket.service.ts @@ -0,0 +1,7 @@ +import { Injectable } from "@nestjs/common"; +import { SocketGateway } from "src/socket/socket.gateway"; + +@Injectable() +export class SocketService { + constructor(private readonly socketGateway: SocketGateway) {} +} diff --git a/src/socket/socket.types.ts b/src/socket/socket.types.ts new file mode 100644 index 0000000..94b5c7b --- /dev/null +++ b/src/socket/socket.types.ts @@ -0,0 +1,18 @@ +import { ISession } from "@postgress-db/schema/sessions"; +import { IWorker } from "@postgress-db/schema/workers"; +import { Socket } from "socket.io"; + +type userId = string; +type clientId = string; + +export type ConnectedClient = { + clientId: clientId; + socket: Socket; + session: Pick; + worker: Pick; +}; + +export type ConnectedClients = Record< + userId, + Record +>; diff --git a/yarn.lock b/yarn.lock index 05a4e08..3d7ac64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1720,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" @@ -1756,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" @@ -2310,6 +2327,11 @@ "@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" @@ -2400,6 +2422,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" @@ -2526,6 +2555,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" @@ -2888,7 +2924,7 @@ accept-language-parser@^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.8: +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== @@ -3239,6 +3275,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" @@ -3716,6 +3757,16 @@ cookie@0.5.0, cookie@^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" @@ -3726,7 +3777,7 @@ core-util-is@^1.0.3, core-util-is@~1.0.0: 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== @@ -3821,6 +3872,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" @@ -4026,6 +4084,26 @@ 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-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" @@ -6249,7 +6327,7 @@ 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== @@ -6433,6 +6511,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" @@ -7312,6 +7395,35 @@ 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-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" @@ -7889,6 +8001,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" @@ -8131,6 +8248,11 @@ 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== + xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" From b465d9edfa14874dd78e850961f5fff1903e69a9 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 12:35:44 +0200 Subject: [PATCH 070/180] refactor: order prices service and it's usage in order dishes service --- src/orders/@/services/order-dishes.service.ts | 22 ++++-- src/orders/@/services/order-prices.service.ts | 68 +++++++++++++++++++ src/orders/@queue/orders-queue.processor.ts | 44 ------------ src/orders/orders.module.ts | 8 ++- 4 files changed, 90 insertions(+), 52 deletions(-) create mode 100644 src/orders/@/services/order-prices.service.ts diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 93641e1..91015de 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -3,11 +3,12 @@ import { NotFoundException } from "@core/errors/exceptions/not-found.exception"; import { Inject, Injectable } from "@nestjs/common"; import { Schema } from "@postgress-db/drizzle.module"; import { orderDishes } from "@postgress-db/schema/order-dishes"; -import { eq } from "drizzle-orm"; +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 { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; +import { OrderPricesService } from "src/orders/@/services/order-prices.service"; import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @Injectable() @@ -16,19 +17,26 @@ export class OrderDishesService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, private readonly ordersProducer: OrdersQueueProducer, + private readonly orderPricesService: OrderPricesService, ) {} - private async getOrder(orderId: string) { - const order = await this.pg.query.orders.findFirst({ + private readonly getOrderStatement = this.pg.query.orders + .findFirst({ where: (orders, { eq, and }) => and( - eq(orders.id, orderId), + 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) { @@ -151,7 +159,7 @@ export class OrderDishesService { id: orderDishes.id, }); - await this.ordersProducer.recalculatePrices(orderId); + await this.orderPricesService.calculateOrderTotals(orderId); return orderDish; } @@ -184,7 +192,7 @@ export class OrderDishesService { }) .where(eq(orderDishes.id, orderDishId)); - await this.ordersProducer.recalculatePrices(orderDish.orderId); + await this.orderPricesService.calculateOrderTotals(orderDish.orderId); } public async remove(orderDishId: string) { @@ -199,6 +207,6 @@ export class OrderDishesService { .set({ isRemoved: true, removedAt: new Date() }) .where(eq(orderDishes.id, orderDishId)); - await this.ordersProducer.recalculatePrices(orderDish.orderId); + await this.orderPricesService.calculateOrderTotals(orderDish.orderId); } } diff --git a/src/orders/@/services/order-prices.service.ts b/src/orders/@/services/order-prices.service.ts new file mode 100644 index 0000000..9557a51 --- /dev/null +++ b/src/orders/@/services/order-prices.service.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { orders } from "@postgress-db/schema/orders"; +import { eq, sql } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; + +@Injectable() +export class OrderPricesService { + private readonly logger = new Logger(OrderPricesService.name); + + private readonly getOrderDishesStatement = this.pg.query.orderDishes + .findMany({ + where: (orderDishes, { eq, and, gt }) => + and( + eq(orderDishes.orderId, sql.placeholder("orderId")), + eq(orderDishes.isRemoved, false), + gt(orderDishes.quantity, 0), + ), + columns: { + price: true, + quantity: true, + finalPrice: true, + surchargeAmount: true, + discountAmount: true, + }, + }) + .prepare(`${OrderPricesService.name}_calculateOrderTotals_getOrderDishes`); + + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + public async calculateOrderTotals(orderId: string) { + const orderDishes = await this.getOrderDishesStatement.execute({ + orderId, + }); + + 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.pg + .update(orders) + .set({ + subtotal: prices.subtotal.toString(), + surchargeAmount: prices.surchargeAmount.toString(), + discountAmount: prices.discountAmount.toString(), + total: prices.total.toString(), + }) + .where(eq(orders.id, orderId)); + } +} diff --git a/src/orders/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts index 0f87a8e..e17aad4 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -51,49 +51,5 @@ export class OrdersQueueProcessor extends WorkerHost { */ private async recalculatePrices(data: RecalculatePricesJobDto) { const { orderId } = data; - - const orderDishes = 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: { - 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.pg - .update(orders) - .set({ - subtotal: prices.subtotal.toString(), - surchargeAmount: prices.surchargeAmount.toString(), - discountAmount: prices.discountAmount.toString(), - total: prices.total.toString(), - }) - .where(eq(orders.id, orderId)); } } diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index 0c25d6b..57b5cc1 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -3,6 +3,7 @@ import { DrizzleModule } from "@postgress-db/drizzle.module"; import { GuestsModule } from "src/guests/guests.module"; import { OrdersController } from "src/orders/@/orders.controller"; import { OrderDishesService } from "src/orders/@/services/order-dishes.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"; @@ -10,7 +11,12 @@ import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders @Module({ imports: [DrizzleModule, GuestsModule, OrdersQueueModule], - providers: [OrdersService, DispatcherOrdersService, OrderDishesService], + providers: [ + OrdersService, + DispatcherOrdersService, + OrderDishesService, + OrderPricesService, + ], controllers: [OrdersController, DispatcherOrdersController], exports: [OrdersService, OrderDishesService], }) From 7538ac632ae74b48220a820fbb6380bb6bbcd36e Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 12:48:49 +0200 Subject: [PATCH 071/180] feat: order dishes data update reliability with transaction --- src/@base/drizzle/drizzle.module.ts | 14 ++++ src/orders/@/services/order-dishes.service.ts | 68 +++++++++++-------- src/orders/@/services/order-prices.service.ts | 33 +++++---- 3 files changed, 71 insertions(+), 44 deletions(-) diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index eb7e314..505c37c 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -37,6 +37,20 @@ export const schema = { }; 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: [ diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 91015de..4dc7eec 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -143,23 +143,29 @@ export class OrderDishesService { const price = Number(dish.price); - const [orderDish] = await this.pg - .insert(orderDishes) - .values({ - orderId, - dishId: payload.dishId, - name: dish.name, - status: "pending", - quantity, - isAdditional, - price: String(price), - finalPrice: String(price), - }) - .returning({ - id: orderDishes.id, + const orderDish = await this.pg.transaction(async (tx) => { + const [orderDish] = await tx + .insert(orderDishes) + .values({ + orderId, + dishId: payload.dishId, + name: dish.name, + status: "pending", + quantity, + isAdditional, + price: String(price), + finalPrice: String(price), + }) + .returning({ + id: orderDishes.id, + }); + + await this.orderPricesService.calculateOrderTotals(orderId, { + tx, }); - await this.orderPricesService.calculateOrderTotals(orderId); + return orderDish; + }); return orderDish; } @@ -185,14 +191,18 @@ export class OrderDishesService { throw new BadRequestException("errors.order-dishes.is-removed"); } - await this.pg - .update(orderDishes) - .set({ - quantity, - }) - .where(eq(orderDishes.id, orderDishId)); + await this.pg.transaction(async (tx) => { + await tx + .update(orderDishes) + .set({ + quantity, + }) + .where(eq(orderDishes.id, orderDishId)); - await this.orderPricesService.calculateOrderTotals(orderDish.orderId); + await this.orderPricesService.calculateOrderTotals(orderDish.orderId, { + tx, + }); + }); } public async remove(orderDishId: string) { @@ -202,11 +212,15 @@ export class OrderDishesService { throw new BadRequestException("errors.order-dishes.already-removed"); } - await this.pg - .update(orderDishes) - .set({ isRemoved: true, removedAt: new Date() }) - .where(eq(orderDishes.id, orderDishId)); + await this.pg.transaction(async (tx) => { + await tx + .update(orderDishes) + .set({ isRemoved: true, removedAt: new Date() }) + .where(eq(orderDishes.id, orderDishId)); - await this.orderPricesService.calculateOrderTotals(orderDish.orderId); + await this.orderPricesService.calculateOrderTotals(orderDish.orderId, { + tx, + }); + }); } } diff --git a/src/orders/@/services/order-prices.service.ts b/src/orders/@/services/order-prices.service.ts index 9557a51..b369680 100644 --- a/src/orders/@/services/order-prices.service.ts +++ b/src/orders/@/services/order-prices.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; -import { Schema } from "@postgress-db/drizzle.module"; +import { DrizzleTransaction, Schema } from "@postgress-db/drizzle.module"; import { orders } from "@postgress-db/schema/orders"; -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; @@ -9,11 +9,21 @@ import { PG_CONNECTION } from "src/constants"; export class OrderPricesService { private readonly logger = new Logger(OrderPricesService.name); - private readonly getOrderDishesStatement = this.pg.query.orderDishes - .findMany({ + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + 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, sql.placeholder("orderId")), + eq(orderDishes.orderId, orderId), eq(orderDishes.isRemoved, false), gt(orderDishes.quantity, 0), ), @@ -24,17 +34,6 @@ export class OrderPricesService { surchargeAmount: true, discountAmount: true, }, - }) - .prepare(`${OrderPricesService.name}_calculateOrderTotals_getOrderDishes`); - - constructor( - @Inject(PG_CONNECTION) - private readonly pg: NodePgDatabase, - ) {} - - public async calculateOrderTotals(orderId: string) { - const orderDishes = await this.getOrderDishesStatement.execute({ - orderId, }); if (!orderDishes.length) { @@ -55,7 +54,7 @@ export class OrderPricesService { { subtotal: 0, surchargeAmount: 0, discountAmount: 0, total: 0 }, ); - await this.pg + await tx .update(orders) .set({ subtotal: prices.subtotal.toString(), From 9a3f3d3c9d562a592e930acdba85127fc7d9248c Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 14:03:46 +0200 Subject: [PATCH 072/180] feat: check table number statement --- src/orders/@/services/orders.service.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 5ff78c9..50116c1 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -5,7 +5,7 @@ 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, eq } from "drizzle-orm"; +import { count, desc, eq, sql } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; import { GuestsService } from "src/guests/guests.service"; @@ -40,17 +40,24 @@ export class OrdersService { return number; } - public async checkTableNumber(restaurantId: string, tableNumber: string) { - const order = await this.pg.query.orders.findFirst({ + private readonly checkTableNumberStatement = this.pg.query.orders + .findFirst({ where: (orders, { eq, and, inArray }) => and( - eq(orders.tableNumber, tableNumber), - eq(orders.restaurantId, restaurantId), + 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) { + const order = await this.checkTableNumberStatement.execute({ + restaurantId, + tableNumber, }); if (order) { From c4569db77734f7100a81615bb5cb60722f4721c7 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 14:19:40 +0200 Subject: [PATCH 073/180] refactor: rename socket folder --- src/{socket => @socket}/socket.gateway.ts | 0 src/{socket => @socket}/socket.module.ts | 2 +- src/{socket => @socket}/socket.service.ts | 2 +- src/{socket => @socket}/socket.types.ts | 0 src/app.module.ts | 2 +- 5 files changed, 3 insertions(+), 3 deletions(-) rename src/{socket => @socket}/socket.gateway.ts (100%) rename src/{socket => @socket}/socket.module.ts (82%) rename src/{socket => @socket}/socket.service.ts (72%) rename src/{socket => @socket}/socket.types.ts (100%) diff --git a/src/socket/socket.gateway.ts b/src/@socket/socket.gateway.ts similarity index 100% rename from src/socket/socket.gateway.ts rename to src/@socket/socket.gateway.ts diff --git a/src/socket/socket.module.ts b/src/@socket/socket.module.ts similarity index 82% rename from src/socket/socket.module.ts rename to src/@socket/socket.module.ts index 48791e5..7f35d79 100644 --- a/src/socket/socket.module.ts +++ b/src/@socket/socket.module.ts @@ -1,6 +1,6 @@ import { Module } from "@nestjs/common"; +import { SocketGateway } from "src/@socket/socket.gateway"; import { AuthModule } from "src/auth/auth.module"; -import { SocketGateway } from "src/socket/socket.gateway"; import { SocketService } from "./socket.service"; diff --git a/src/socket/socket.service.ts b/src/@socket/socket.service.ts similarity index 72% rename from src/socket/socket.service.ts rename to src/@socket/socket.service.ts index f4611a8..8564d41 100644 --- a/src/socket/socket.service.ts +++ b/src/@socket/socket.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { SocketGateway } from "src/socket/socket.gateway"; +import { SocketGateway } from "src/@socket/socket.gateway"; @Injectable() export class SocketService { diff --git a/src/socket/socket.types.ts b/src/@socket/socket.types.ts similarity index 100% rename from src/socket/socket.types.ts rename to src/@socket/socket.types.ts diff --git a/src/app.module.ts b/src/app.module.ts index 1234d74..58a4d52 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { import { ZodValidationPipe } from "nestjs-zod"; import { EncryptionModule } from "src/@base/encryption/encryption.module"; import { S3Module } from "src/@base/s3/s3.module"; +import { SocketModule } from "src/@socket/socket.module"; import { AddressesModule } from "src/addresses/addresses.module"; import { DishCategoriesModule } from "src/dish-categories/dish-categories.module"; import { DishesModule } from "src/dishes/dishes.module"; @@ -32,7 +33,6 @@ import { AuthModule } from "./auth/auth.module"; import { SessionAuthGuard } from "./auth/guards/session-auth.guard"; import { RestaurantsModule } from "./restaurants/restaurants.module"; import { WorkersModule } from "./workers/workers.module"; -import { SocketModule } from './socket/socket.module'; @Module({ imports: [ From 8c90f485d3249a1dbb9c7a0ff9dc79dd6579b88d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 15:00:01 +0200 Subject: [PATCH 074/180] feat: scratch for socket notifications for connected users --- src/@base/drizzle/schema/workers.ts | 2 + src/@core/types/general.ts | 6 ++ src/@socket/socket.service.ts | 4 ++ src/orders/@/services/orders.service.ts | 32 +++++++-- src/orders/@queue/dto/crud-update.job.ts | 9 +++ src/orders/@queue/index.ts | 1 + src/orders/@queue/orders-queue.module.ts | 5 +- src/orders/@queue/orders-queue.processor.ts | 14 ++++ src/orders/@queue/orders-queue.producer.ts | 8 +-- .../orders-socket-notifier.service.ts | 67 +++++++++++++++++++ 10 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 src/orders/@queue/dto/crud-update.job.ts create mode 100644 src/orders/@queue/services/orders-socket-notifier.service.ts diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 0b68e83..997d264 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -27,6 +27,8 @@ export const workerRoleEnum = pgEnum("workerRoleEnum", [ export const ZodWorkerRole = z.enum(workerRoleEnum.enumValues); +export type IRole = (typeof workerRoleEnum.enumValues)[number]; + export const workers = pgTable("workers", { id: uuid("id").defaultRandom().primaryKey(), name: text("name").notNull().default("N/A"), diff --git a/src/@core/types/general.ts b/src/@core/types/general.ts index ba5343b..bf64a6e 100644 --- a/src/@core/types/general.ts +++ b/src/@core/types/general.ts @@ -9,3 +9,9 @@ export enum DayOfWeekEnum { } export type DayOfWeek = keyof typeof DayOfWeekEnum; + +export enum CrudAction { + CREATE = "CREATE", + UPDATE = "UPDATE", + DELETE = "DELETE", +} diff --git a/src/@socket/socket.service.ts b/src/@socket/socket.service.ts index 8564d41..7b87178 100644 --- a/src/@socket/socket.service.ts +++ b/src/@socket/socket.service.ts @@ -4,4 +4,8 @@ import { SocketGateway } from "src/@socket/socket.gateway"; @Injectable() export class SocketService { constructor(private readonly socketGateway: SocketGateway) {} + + public getClients() { + return this.socketGateway.connectedClients; + } } diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 50116c1..08a672b 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -12,6 +12,7 @@ import { GuestsService } from "src/guests/guests.service"; import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; import { UpdateOrderDto } from "src/orders/@/dtos/update-order.dto"; import { OrderEntity } from "src/orders/@/entities/order.entity"; +import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @Injectable() export class OrdersService { @@ -19,6 +20,7 @@ export class OrdersService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, private readonly guestsService: GuestsService, + private readonly ordersQueueProducer: OrdersQueueProducer, ) {} private async generateOrderNumber() { @@ -54,13 +56,17 @@ export class OrdersService { }) .prepare(`${OrdersService.name}_checkTableNumber`); - public async checkTableNumber(restaurantId: string, tableNumber: string) { + public async checkTableNumber( + restaurantId: string, + tableNumber: string, + orderId?: string, + ) { const order = await this.checkTableNumberStatement.execute({ restaurantId, tableNumber, }); - if (order) { + if (order && order.id !== orderId) { throw new BadRequestException( "errors.orders.table-number-is-already-taken", { @@ -70,7 +76,7 @@ export class OrdersService { } } - public async checkDto(dto: UpdateOrderDto) { + 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 === "") { @@ -92,7 +98,7 @@ export class OrdersService { } if (dto.restaurantId) { - await this.checkTableNumber(dto.restaurantId, dto.tableNumber); + await this.checkTableNumber(dto.restaurantId, dto.tableNumber, orderId); } } @@ -150,12 +156,18 @@ export class OrdersService { }); const order = await this.findById(createdOrder.id); - // await this.ordersProducer.orderUpdate("create", order); + + await this.ordersQueueProducer.crudUpdate({ + action: "CREATE", + orderId: createdOrder.id, + order, + }); + return order; } async update(id: string, dto: UpdateOrderDto): Promise { - await this.checkDto(dto); + await this.checkDto(dto, id); const order = await this.pg.query.orders.findFirst({ where: (orders, { eq }) => eq(orders.id, id), @@ -208,7 +220,13 @@ export class OrdersService { .returning({ id: orders.id }); const updatedOrderEntity = await this.findById(updatedOrder.id); - // await this.ordersProducer.orderUpdate("update", updatedOrderEntity); + + await this.ordersQueueProducer.crudUpdate({ + action: "UPDATE", + orderId: id, + order: updatedOrderEntity, + }); + return updatedOrderEntity; } 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..c0396b0 --- /dev/null +++ b/src/orders/@queue/dto/crud-update.job.ts @@ -0,0 +1,9 @@ +import { CrudAction } from "@core/types/general"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +export class OrderCrudUpdateJobDto { + orderId: string; + order: OrderEntity; + action: `${CrudAction}`; + calledByUserId?: string; +} diff --git a/src/orders/@queue/index.ts b/src/orders/@queue/index.ts index 8360c31..f81a29d 100644 --- a/src/orders/@queue/index.ts +++ b/src/orders/@queue/index.ts @@ -1,5 +1,6 @@ export const ORDERS_QUEUE = "orders"; export enum OrderQueueJobName { + CRUD_UPDATE = "crud-update", RECALCULATE_PRICES = "recalculate-prices", } diff --git a/src/orders/@queue/orders-queue.module.ts b/src/orders/@queue/orders-queue.module.ts index 48af134..c824f19 100644 --- a/src/orders/@queue/orders-queue.module.ts +++ b/src/orders/@queue/orders-queue.module.ts @@ -1,18 +1,21 @@ import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.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"; @Module({ imports: [ + SocketModule, DrizzleModule, BullModule.registerQueue({ name: ORDERS_QUEUE, }), ], - providers: [OrdersQueueProcessor, OrdersQueueProducer], + 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 index e17aad4..0b52186 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -7,7 +7,9 @@ import { eq } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; +import { OrderCrudUpdateJobDto } 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 { @@ -16,6 +18,7 @@ export class OrdersQueueProcessor extends WorkerHost { constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly ordersSocketNotifier: OrdersSocketNotifier, ) { super(); } @@ -34,6 +37,11 @@ export class OrdersQueueProcessor extends WorkerHost { break; } + case OrderQueueJobName.CRUD_UPDATE: { + await this.crudUpdate(data as OrderCrudUpdateJobDto); + break; + } + default: { throw new Error(`Unknown job name`); } @@ -52,4 +60,10 @@ export class OrdersQueueProcessor extends WorkerHost { private async recalculatePrices(data: RecalculatePricesJobDto) { const { orderId } = data; } + + private async crudUpdate(data: OrderCrudUpdateJobDto) { + // log + // notify users + await this.ordersSocketNotifier.handle(data.order); + } } diff --git a/src/orders/@queue/orders-queue.producer.ts b/src/orders/@queue/orders-queue.producer.ts index 67c432b..694d6b4 100644 --- a/src/orders/@queue/orders-queue.producer.ts +++ b/src/orders/@queue/orders-queue.producer.ts @@ -2,6 +2,7 @@ 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 } from "src/orders/@queue/dto/crud-update.job"; import { RecalculatePricesJobDto } from "src/orders/@queue/dto/recalculate-prices-job.dto"; @Injectable() @@ -24,11 +25,10 @@ export class OrdersQueueProducer { /** * When order is: created, updated, removed - * This job should be triggered. It's main task to: - * - 1 - * - 2 */ - public async handleUpdate() {} + public async crudUpdate(payload: OrderCrudUpdateJobDto) { + return this.addJob(OrderQueueJobName.CRUD_UPDATE, payload); + } /** * This producer creates a job that recalculates prices of the order based on the order dishes 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..b6c3dae --- /dev/null +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { IRole } from "@postgress-db/schema/workers"; +import { SocketService } from "src/@socket/socket.service"; +import { ConnectedClient, ConnectedClients } from "src/@socket/socket.types"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; + +type workerId = string; + +@Injectable() +export class OrdersSocketNotifier { + private readonly logger = new Logger(OrdersSocketNotifier.name); + + constructor(private readonly socketService: SocketService) {} + + private makeWorkersByRoleMap(clients: ConnectedClients) { + const workersByRoleMap: Record< + IRole, + Record> + > = { + SYSTEM_ADMIN: {}, + CHIEF_ADMIN: {}, + ADMIN: {}, + KITCHENER: {}, + WAITER: {}, + CASHIER: {}, + DISPATCHER: {}, + COURIER: {}, + }; + + Object.entries(clients).forEach(([, clientObject]) => { + // WTF user connected with no clients? + if (Object.keys(clientObject).length === 0) { + return; + } + + const [, client] = Object.entries(clientObject)[0]; + + const { worker, session } = client; + + if (!workersByRoleMap?.[worker.role]) { + workersByRoleMap[worker.role] = {}; + } + + // if worker already exists, skip + if (!!workersByRoleMap[worker.role]?.[worker.id]) { + return; + } + + workersByRoleMap[worker.role][worker.id] = { + worker, + session, + }; + }); + + return workersByRoleMap; + } + + /** + * ! 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 = this.socketService.getClients(); + const workersByRoleMap = this.makeWorkersByRoleMap(clients); + } +} From 8028d9b5365d6acdff864216e0e4e77179bf9dd7 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 17:00:45 +0200 Subject: [PATCH 075/180] feat: saving clients from socket gateways to redis (sync steps) --- .example.env | 3 +- package.json | 1 + src/@base/redis/channels.ts | 5 + src/@socket/socket.gateway.ts | 176 +++++++++++++++--- src/@socket/socket.service.ts | 8 +- src/@socket/socket.types.ts | 9 + src/@socket/socket.utils.ts | 38 ++++ src/app.module.ts | 5 +- .../orders-socket-notifier.service.ts | 11 +- yarn.lock | 26 +++ 10 files changed, 253 insertions(+), 29 deletions(-) create mode 100644 src/@base/redis/channels.ts create mode 100644 src/@socket/socket.utils.ts diff --git a/.example.env b/.example.env index 26610a7..b23563e 100644 --- a/.example.env +++ b/.example.env @@ -5,4 +5,5 @@ INITIAL_ADMIN_PASSWORD=123456 PORT=6701 DADATA_API_TOKEN= GOOGLE_MAPS_API_KEY= -REDIS_URL= \ No newline at end of file +REDIS_URL= +API_SECRET_KEY= \ No newline at end of file diff --git a/package.json b/package.json index 4708b2b..3ce202a 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "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" }, diff --git a/src/@base/redis/channels.ts b/src/@base/redis/channels.ts new file mode 100644 index 0000000..2783cb0 --- /dev/null +++ b/src/@base/redis/channels.ts @@ -0,0 +1,5 @@ +export enum RedisChannels { + COMMON = 1, + BULLMQ = 2, + SOCKET = 3, +} diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index 4052783..20d59f2 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -1,3 +1,4 @@ +import env from "@core/env"; import { Logger } from "@nestjs/common"; import { OnGatewayConnection, @@ -5,52 +6,175 @@ import { WebSocketGateway, WebSocketServer, } from "@nestjs/websockets"; -import { parse as parseCookie } from "cookie"; +import Redis from "ioredis"; import { Socket } from "socket.io"; -import { AUTH_COOKIES } from "src/auth/auth.types"; +import { RedisChannels } from "src/@base/redis/channels"; +import { SocketUtils } from "src/@socket/socket.utils"; import { AuthService } from "src/auth/services/auth.service"; -import { ConnectedClients } from "./socket.types"; +import { ConnectedClients, RedisConnectedClients } from "./socket.types"; @WebSocketGateway() export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { + // Instance of the socket server @WebSocketServer() private server: Socket; - private serviceId: string; + private readonly logger = new Logger(SocketGateway.name); + // Gateway ID for synchronization between gateways + private gatewayId: string; + + // For synchronization between gateways + private publisherRedis: Redis; + private subscriberRedis: Redis; + private discoveryInterval: NodeJS.Timeout; + + // Local state for gateway public readonly connectedClients: ConnectedClients = {}; public readonly clientIdToWorkerIdMap: Map = new Map(); - constructor(private readonly authService: AuthService) {} + private readonly REDIS_CLIENTS_TTL = 5; // seconds + + constructor(private readonly authService: AuthService) { + this.gatewayId = SocketUtils.generateGatewayId(); + } - private getClientAuthCookie(socket: Socket): string | null { - const cookies = parseCookie(socket.handshake.headers.cookie || ""); - const auth = cookies?.[AUTH_COOKIES.token]; + async onModuleInit() { + this.publisherRedis = this.getRedisClient(); + this.subscriberRedis = this.getRedisClient(); - return auth ?? null; + this.subscriberRedis.subscribe(this.gatewayId); + this.subscriberRedis.on("message", (channel, message) => { + // console.log(channel, message); + this.logger.debug(channel, message); + }); + + await this.startChannelDiscovery(); } - private getUserAgent(socket: Socket): string { - const headers = socket.handshake.headers; + async onModuleDestroy() { + if (this.discoveryInterval) { + clearInterval(this.discoveryInterval); + } - return (headers["user-agent"] || headers["User-Agent"]) as string; + await Promise.all([ + this.publisherRedis?.disconnect(), + this.subscriberRedis?.disconnect(), + ]); } - private getClientIp(socket: Socket): string { - return ( - socket.handshake.address ?? - socket.handshake.headers["x-forwarded-for"] ?? - socket.conn.remoteAddress - ); + private async startChannelDiscovery() { + await this.updateDiscoveryStatus(); + + this.discoveryInterval = setInterval(async () => { + await this.updateDiscoveryStatus(); + }, 1000); + } + + private async updateDiscoveryStatus() { + try { + const clientsToSync: RedisConnectedClients = {}; + + // Convert local clients to redis format (without socket) + Object.entries(this.connectedClients).forEach(([workerId, clients]) => { + clientsToSync[workerId] = {}; + Object.entries(clients).forEach(([clientId, client]) => { + const { socket, ...clientWithoutSocket } = client; + + socket; + + clientsToSync[workerId][clientId] = { + ...clientWithoutSocket, + gatewayId: this.gatewayId, + }; + }); + }); + + // Save to Redis with TTL + await this.publisherRedis.setex( + `${this.gatewayId}:clients`, + this.REDIS_CLIENTS_TTL, + JSON.stringify(clientsToSync), + ); + } catch (error) { + this.logger.error("Failed to update discovery status:", error); + } + } + + public async getAllConnectedClients(): Promise { + try { + // Get all gateway keys + const gatewayKeys = await this.publisherRedis.keys("*:clients"); + + // Fetch all clients data from Redis + const clientsData = await Promise.all( + gatewayKeys.map(async (key) => { + const data = await this.publisherRedis.get(key); + return data ? JSON.parse(data) : {}; + }), + ); + + // Merge all clients with local clients + const allClients: RedisConnectedClients = {}; + + // First add local clients + Object.entries(this.connectedClients).forEach(([workerId, clients]) => { + allClients[workerId] = {}; + Object.entries(clients).forEach(([clientId, client]) => { + const { socket, ...clientWithoutSocket } = client; + + socket; + + allClients[workerId][clientId] = { + ...clientWithoutSocket, + gatewayId: this.gatewayId, + }; + }); + }); + + // Then merge with Redis clients + clientsData.forEach((gatewayClients) => { + Object.entries(gatewayClients).forEach( + ([workerId, clients]: [string, any]) => { + if (!allClients[workerId]) { + allClients[workerId] = {}; + } + Object.assign(allClients[workerId], clients); + }, + ); + }); + + return allClients; + } catch (error) { + this.logger.error("Failed to get all connected clients:", error); + return {}; + } + } + + private getRedisClient() { + 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) => { + console.error("Redis client error:", error); + }); + + return client; } async handleConnection(socket: Socket): Promise { try { const clientId = socket.id; - const signed = this.getClientAuthCookie(socket); - const httpAgent = this.getUserAgent(socket); - const clientIp = this.getClientIp(socket); + const signed = SocketUtils.getClientAuthCookie(socket); + const httpAgent = SocketUtils.getUserAgent(socket); + const clientIp = SocketUtils.getClientIp(socket); if (!signed) { throw new Error("No signed cookie found"); @@ -96,6 +220,9 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { this.clientIdToWorkerIdMap.set(clientId, session.workerId); socket.emit("connected", session.worker); + + // Trigger immediate sync after successful connection + await this.updateDiscoveryStatus(); } catch (error) { this.logger.error(error); socket.disconnect(true); @@ -112,6 +239,13 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { delete this.connectedClients[workerId][clientId]; + if (Object.keys(this.connectedClients[workerId]).length === 0) { + delete this.connectedClients[workerId]; + } + this.clientIdToWorkerIdMap.delete(clientId); + + // Trigger immediate sync after client disconnection + await this.updateDiscoveryStatus(); } } diff --git a/src/@socket/socket.service.ts b/src/@socket/socket.service.ts index 7b87178..e328c72 100644 --- a/src/@socket/socket.service.ts +++ b/src/@socket/socket.service.ts @@ -1,11 +1,15 @@ +import env from "@core/env"; import { Injectable } from "@nestjs/common"; +import Redis from "ioredis"; +import { RedisChannels } from "src/@base/redis/channels"; import { SocketGateway } from "src/@socket/socket.gateway"; +import { v4 as uuidv4 } from "uuid"; @Injectable() export class SocketService { constructor(private readonly socketGateway: SocketGateway) {} - public getClients() { - return this.socketGateway.connectedClients; + public async getClients() { + return await this.socketGateway.getAllConnectedClients(); } } diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index 94b5c7b..784cfa1 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -16,3 +16,12 @@ export type ConnectedClients = Record< userId, Record >; + +export type RedisConnectedClient = Omit & { + gatewayId: string; +}; + +export type RedisConnectedClients = Record< + userId, + Record +>; diff --git a/src/@socket/socket.utils.ts b/src/@socket/socket.utils.ts new file mode 100644 index 0000000..a4c968b --- /dev/null +++ b/src/@socket/socket.utils.ts @@ -0,0 +1,38 @@ +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 generateGatewayId() { + const gatewayId = uuidv4() + .replaceAll("-", "") + .replaceAll(" ", "") + .replaceAll("_", "") + .replaceAll(":", "") + .replaceAll(".", ""); + + return `SOCKET_GATEWAY_${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 + ); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 58a4d52..0d28564 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { } from "nestjs-i18n"; import { ZodValidationPipe } from "nestjs-zod"; import { EncryptionModule } from "src/@base/encryption/encryption.module"; +import { RedisChannels } from "src/@base/redis/channels"; import { S3Module } from "src/@base/s3/s3.module"; import { SocketModule } from "src/@socket/socket.module"; import { AddressesModule } from "src/addresses/addresses.module"; @@ -55,13 +56,13 @@ import { WorkersModule } from "./workers/workers.module"; }), RedisModule.forRoot({ config: { - url: `${env.REDIS_URL}/1`, + url: `${env.REDIS_URL}/${RedisChannels.COMMON}`, }, }), BullModule.forRoot({ prefix: "toite", connection: { - url: `${env.REDIS_URL}/2`, + url: `${env.REDIS_URL}/${RedisChannels.BULLMQ}`, }, }), EncryptionModule, diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index b6c3dae..72b8ab6 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -1,7 +1,10 @@ import { Injectable, Logger } from "@nestjs/common"; import { IRole } from "@postgress-db/schema/workers"; import { SocketService } from "src/@socket/socket.service"; -import { ConnectedClient, ConnectedClients } from "src/@socket/socket.types"; +import { + ConnectedClient, + RedisConnectedClients, +} from "src/@socket/socket.types"; import { OrderEntity } from "src/orders/@/entities/order.entity"; type workerId = string; @@ -12,7 +15,7 @@ export class OrdersSocketNotifier { constructor(private readonly socketService: SocketService) {} - private makeWorkersByRoleMap(clients: ConnectedClients) { + private makeWorkersByRoleMap(clients: RedisConnectedClients) { const workersByRoleMap: Record< IRole, Record> @@ -61,7 +64,9 @@ export class OrdersSocketNotifier { * @param order */ public async handle(order: OrderEntity) { - const clients = this.socketService.getClients(); + const clients = await this.socketService.getClients(); const workersByRoleMap = this.makeWorkersByRoleMap(clients); + + console.log(clients); } } diff --git a/yarn.lock b/yarn.lock index 3d7ac64..1e3da49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4084,6 +4084,17 @@ 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" @@ -7403,6 +7414,16 @@ socket.io-adapter@~2.5.2: 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" @@ -8253,6 +8274,11 @@ ws@~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" From 768e9b2e9bd4120a94511951c14dae561a9202b4 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 18:53:00 +0200 Subject: [PATCH 076/180] refactor: socket gateway --- src/@socket/socket.gateway.ts | 297 +++++++----------- src/@socket/socket.service.ts | 2 +- src/@socket/socket.types.ts | 30 +- src/@socket/socket.utils.ts | 7 +- .../orders-socket-notifier.service.ts | 78 +++-- 5 files changed, 167 insertions(+), 247 deletions(-) diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index 20d59f2..ac6ffef 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -9,243 +9,180 @@ import { import Redis from "ioredis"; import { Socket } from "socket.io"; import { RedisChannels } from "src/@base/redis/channels"; -import { SocketUtils } from "src/@socket/socket.utils"; +import { GatewayClient, GatewayClients } from "src/@socket/socket.types"; import { AuthService } from "src/auth/services/auth.service"; -import { ConnectedClients, RedisConnectedClients } from "./socket.types"; +import { SocketUtils } from "./socket.utils"; @WebSocketGateway() export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { - // Instance of the socket server @WebSocketServer() private server: Socket; private readonly logger = new Logger(SocketGateway.name); // Gateway ID for synchronization between gateways - private gatewayId: string; + private readonly gatewayId: string; + private readonly sharedGatewaysDataKey = `${SocketUtils.commonGatewaysIdentifier}:shared`; - // For synchronization between gateways + // Redis instances for synchronization between gateways private publisherRedis: Redis; private subscriberRedis: Redis; - private discoveryInterval: NodeJS.Timeout; - - // Local state for gateway - public readonly connectedClients: ConnectedClients = {}; - public readonly clientIdToWorkerIdMap: Map = new Map(); + // 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 clients: GatewayClients = []; + private clientsSocketMap: Map = new Map(); + constructor(private readonly authService: AuthService) { this.gatewayId = SocketUtils.generateGatewayId(); } - async onModuleInit() { - this.publisherRedis = this.getRedisClient(); - this.subscriberRedis = this.getRedisClient(); + /** + * 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; + }, + }); - this.subscriberRedis.subscribe(this.gatewayId); - this.subscriberRedis.on("message", (channel, message) => { - // console.log(channel, message); - this.logger.debug(channel, message); + client.on("error", (error) => { + this.logger.error("Redis client error:", error); }); - await this.startChannelDiscovery(); + return client; } - async onModuleDestroy() { - if (this.discoveryInterval) { - clearInterval(this.discoveryInterval); - } + async onModuleInit() { + this.publisherRedis = this._getRedis(); + this.subscriberRedis = this._getRedis(); - await Promise.all([ - this.publisherRedis?.disconnect(), - this.subscriberRedis?.disconnect(), - ]); - } + this.subscriberRedis.subscribe(this.gatewayId); + this.subscriberRedis.on("message", (channel, message) => { + this.logger.debug(channel, message); + }); - private async startChannelDiscovery() { - await this.updateDiscoveryStatus(); + await this._updateDiscovery(); this.discoveryInterval = setInterval(async () => { - await this.updateDiscoveryStatus(); - }, 1000); + await this._updateDiscovery(); + }, this.DISCOVERY_INTERVAL); } - private async updateDiscoveryStatus() { + /** + * Update the discovery status in Redis + */ + private async _updateDiscovery() { try { - const clientsToSync: RedisConnectedClients = {}; - - // Convert local clients to redis format (without socket) - Object.entries(this.connectedClients).forEach(([workerId, clients]) => { - clientsToSync[workerId] = {}; - Object.entries(clients).forEach(([clientId, client]) => { - const { socket, ...clientWithoutSocket } = client; - - socket; - - clientsToSync[workerId][clientId] = { - ...clientWithoutSocket, - gatewayId: this.gatewayId, - }; - }); - }); - - // Save to Redis with TTL await this.publisherRedis.setex( `${this.gatewayId}:clients`, this.REDIS_CLIENTS_TTL, - JSON.stringify(clientsToSync), + JSON.stringify(this.clients), ); } catch (error) { - this.logger.error("Failed to update discovery status:", error); + this.logger.error(error); } } - public async getAllConnectedClients(): Promise { - try { - // Get all gateway keys - const gatewayKeys = await this.publisherRedis.keys("*:clients"); - - // Fetch all clients data from Redis - const clientsData = await Promise.all( - gatewayKeys.map(async (key) => { - const data = await this.publisherRedis.get(key); - return data ? JSON.parse(data) : {}; - }), - ); + /** + * Get a worker for the socket connection + * @param socket - The socket connection + * @returns The worker + */ + private async _getWorker(socket: Socket) { + const signed = SocketUtils.getClientAuthCookie(socket); + const httpAgent = SocketUtils.getUserAgent(socket); + const clientIp = SocketUtils.getClientIp(socket); + + if (!signed) { + throw new Error("No signed cookie found"); + } - // Merge all clients with local clients - const allClients: RedisConnectedClients = {}; - - // First add local clients - Object.entries(this.connectedClients).forEach(([workerId, clients]) => { - allClients[workerId] = {}; - Object.entries(clients).forEach(([clientId, client]) => { - const { socket, ...clientWithoutSocket } = client; - - socket; - - allClients[workerId][clientId] = { - ...clientWithoutSocket, - gatewayId: this.gatewayId, - }; - }); - }); - - // Then merge with Redis clients - clientsData.forEach((gatewayClients) => { - Object.entries(gatewayClients).forEach( - ([workerId, clients]: [string, any]) => { - if (!allClients[workerId]) { - allClients[workerId] = {}; - } - Object.assign(allClients[workerId], clients); - }, - ); - }); - - return allClients; - } catch (error) { - this.logger.error("Failed to get all connected clients:", error); - return {}; + 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; } - private getRedisClient() { - const client = new Redis(`${env.REDIS_URL}/${RedisChannels.SOCKET}`, { - maxRetriesPerRequest: 3, - enableReadyCheck: true, - retryStrategy(times) { - const delay = Math.min(times * 50, 2000); - return delay; - }, - }); + /** + * Get all clients from all gateways + * @returns All clients + */ + public async getClients() { + const gatewayKeys = await this.publisherRedis.keys( + `${this.gatewayId}:clients`, + ); - client.on("error", (error) => { - console.error("Redis client error:", error); - }); + const clientsRaw = await this.publisherRedis.mget(gatewayKeys); - return client; + const clients: GatewayClients = clientsRaw + .filter(Boolean) + .map((client) => JSON.parse(String(client))); + + return clients; } - async handleConnection(socket: Socket): Promise { + /** + * Handle a new connection + * @param socket - The socket connection + */ + async handleConnection(socket: Socket) { try { - const clientId = socket.id; - 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"); - } - - if (!this.connectedClients[session.workerId]) { - this.connectedClients[session.workerId] = {}; - } - - this.connectedClients[session.workerId][clientId] = { - clientId, - socket, - session: { - id: session.id, - isActive: session.isActive, - previousId: session.previousId, - }, - worker: { - id: session.workerId, - isBlocked: session.worker.isBlocked, - restaurantId: session.worker.restaurantId, - role: session.worker.role, - }, - }; - - this.clientIdToWorkerIdMap.set(clientId, session.workerId); - - socket.emit("connected", session.worker); - - // Trigger immediate sync after successful connection - await this.updateDiscoveryStatus(); + const worker = await this._getWorker(socket); + + this.clients.push({ + clientId: socket.id, + workerId: worker.id, + gatewayId: this.gatewayId, + } satisfies GatewayClient); + + this.clientsSocketMap.set(socket.id, socket); + + socket.emit("connected", worker); } catch (error) { this.logger.error(error); socket.disconnect(true); } } - async handleDisconnect(socket: Socket): Promise { - const clientId = socket.id; - const workerId = this.clientIdToWorkerIdMap.get(clientId); - - if (!workerId) { - return; - } - - delete this.connectedClients[workerId][clientId]; + /** + * Handle a disconnection + * @param socket - The socket connection + */ + async handleDisconnect(socket: Socket) { + try { + this.clients = this.clients.filter( + (client) => client.clientId !== socket.id, + ); - if (Object.keys(this.connectedClients[workerId]).length === 0) { - delete this.connectedClients[workerId]; + this.clientsSocketMap.delete(socket.id); + } catch (error) { + this.logger.error(error); + socket.disconnect(true); } - - this.clientIdToWorkerIdMap.delete(clientId); - - // Trigger immediate sync after client disconnection - await this.updateDiscoveryStatus(); } } diff --git a/src/@socket/socket.service.ts b/src/@socket/socket.service.ts index e328c72..d0ea685 100644 --- a/src/@socket/socket.service.ts +++ b/src/@socket/socket.service.ts @@ -10,6 +10,6 @@ export class SocketService { constructor(private readonly socketGateway: SocketGateway) {} public async getClients() { - return await this.socketGateway.getAllConnectedClients(); + return await this.socketGateway.getClients(); } } diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index 784cfa1..abce9af 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -1,27 +1,7 @@ -import { ISession } from "@postgress-db/schema/sessions"; -import { IWorker } from "@postgress-db/schema/workers"; -import { Socket } from "socket.io"; - -type userId = string; -type clientId = string; - -export type ConnectedClient = { - clientId: clientId; - socket: Socket; - session: Pick; - worker: Pick; -}; - -export type ConnectedClients = Record< - userId, - Record ->; - -export type RedisConnectedClient = Omit & { +export interface GatewayClient { + clientId: string; gatewayId: string; -}; + workerId: string; +} -export type RedisConnectedClients = Record< - userId, - Record ->; +export type GatewayClients = GatewayClient[]; diff --git a/src/@socket/socket.utils.ts b/src/@socket/socket.utils.ts index a4c968b..c187f4a 100644 --- a/src/@socket/socket.utils.ts +++ b/src/@socket/socket.utils.ts @@ -1,9 +1,14 @@ +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("-", "") @@ -12,7 +17,7 @@ export class SocketUtils { .replaceAll(":", "") .replaceAll(".", ""); - return `SOCKET_GATEWAY_${gatewayId}`; + return `${SocketUtils.commonGatewaysIdentifier}:${gatewayId}`; } public static getClientAuthCookie(socket: Socket): string | null { diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index 72b8ab6..4f51cf3 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -1,10 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { IRole } from "@postgress-db/schema/workers"; import { SocketService } from "src/@socket/socket.service"; -import { - ConnectedClient, - RedisConnectedClients, -} from "src/@socket/socket.types"; import { OrderEntity } from "src/orders/@/entities/order.entity"; type workerId = string; @@ -15,48 +11,50 @@ export class OrdersSocketNotifier { constructor(private readonly socketService: SocketService) {} - private makeWorkersByRoleMap(clients: RedisConnectedClients) { - const workersByRoleMap: Record< - IRole, - Record> - > = { - SYSTEM_ADMIN: {}, - CHIEF_ADMIN: {}, - ADMIN: {}, - KITCHENER: {}, - WAITER: {}, - CASHIER: {}, - DISPATCHER: {}, - COURIER: {}, - }; + // private makeWorkersByRoleMap(clients: RedisConnectedClients) { + // const workersByRoleMap: Record< + // IRole, + // Record> + // > = { + // SYSTEM_ADMIN: {}, + // CHIEF_ADMIN: {}, + // ADMIN: {}, + // KITCHENER: {}, + // WAITER: {}, + // CASHIER: {}, + // DISPATCHER: {}, + // COURIER: {}, + // }; - Object.entries(clients).forEach(([, clientObject]) => { - // WTF user connected with no clients? - if (Object.keys(clientObject).length === 0) { - return; - } + // Object.entries(clients).forEach(([, clientObject]) => { + // // WTF user connected with no clients? + // if (Object.keys(clientObject).length === 0) { + // return; + // } - const [, client] = Object.entries(clientObject)[0]; + // const [, client] = Object.entries(clientObject)[0]; - const { worker, session } = client; + // const { worker, session } = client; - if (!workersByRoleMap?.[worker.role]) { - workersByRoleMap[worker.role] = {}; - } + // if (!workersByRoleMap?.[worker.role]) { + // workersByRoleMap[worker.role] = {}; + // } - // if worker already exists, skip - if (!!workersByRoleMap[worker.role]?.[worker.id]) { - return; - } + // // if worker already exists, skip + // if (!!workersByRoleMap[worker.role]?.[worker.id]) { + // return; + // } - workersByRoleMap[worker.role][worker.id] = { - worker, - session, - }; - }); + // workersByRoleMap[worker.role][worker.id] = { + // clientId: client.clientId, + // gatewayId: client.gatewayId, + // worker, + // session, + // }; + // }); - return workersByRoleMap; - } + // return workersByRoleMap; + // } /** * ! WE SHOULD NOTIFY USERS ONLY IF ORDER HAVE CHANGED DATA @@ -65,7 +63,7 @@ export class OrdersSocketNotifier { */ public async handle(order: OrderEntity) { const clients = await this.socketService.getClients(); - const workersByRoleMap = this.makeWorkersByRoleMap(clients); + // const workersByRoleMap = this.makeWorkersByRoleMap(clients); console.log(clients); } From f077c83a56bd12926f522d361520ce5d78bc3f0d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Feb 2025 19:30:29 +0200 Subject: [PATCH 077/180] feat: local workers data saving --- src/@socket/socket.gateway.ts | 33 ++++++++++++++++++++++++--------- src/@socket/socket.types.ts | 4 ++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index ac6ffef..7d2d3a4 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -9,7 +9,11 @@ import { import Redis from "ioredis"; import { Socket } from "socket.io"; import { RedisChannels } from "src/@base/redis/channels"; -import { GatewayClient, GatewayClients } from "src/@socket/socket.types"; +import { + GatewayClient, + GatewayClients, + GatewayWorker, +} from "src/@socket/socket.types"; import { AuthService } from "src/auth/services/auth.service"; import { SocketUtils } from "./socket.utils"; @@ -23,7 +27,6 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { // Gateway ID for synchronization between gateways private readonly gatewayId: string; - private readonly sharedGatewaysDataKey = `${SocketUtils.commonGatewaysIdentifier}:shared`; // Redis instances for synchronization between gateways private publisherRedis: Redis; @@ -35,8 +38,9 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { private readonly REDIS_CLIENTS_TTL = 5; // seconds // Local state of the gateway - private clients: GatewayClients = []; - private clientsSocketMap: Map = new Map(); + private localClients: GatewayClients = []; + private localClientsSocketMap: Map = new Map(); + private localWorkersMap: Map = new Map(); constructor(private readonly authService: AuthService) { this.gatewayId = SocketUtils.generateGatewayId(); @@ -87,7 +91,13 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { await this.publisherRedis.setex( `${this.gatewayId}:clients`, this.REDIS_CLIENTS_TTL, - JSON.stringify(this.clients), + JSON.stringify(this.localClients), + ); + + await this.publisherRedis.setex( + `${this.gatewayId}:workers`, + this.REDIS_CLIENTS_TTL, + JSON.stringify(Object.fromEntries(this.localWorkersMap.entries())), ); } catch (error) { this.logger.error(error); @@ -154,13 +164,18 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { try { const worker = await this._getWorker(socket); - this.clients.push({ + this.localClients.push({ clientId: socket.id, workerId: worker.id, gatewayId: this.gatewayId, } satisfies GatewayClient); - this.clientsSocketMap.set(socket.id, socket); + this.localClientsSocketMap.set(socket.id, socket); + + this.localWorkersMap.set(worker.id, { + id: worker.id, + role: worker.role, + } satisfies GatewayWorker); socket.emit("connected", worker); } catch (error) { @@ -175,11 +190,11 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { */ async handleDisconnect(socket: Socket) { try { - this.clients = this.clients.filter( + this.localClients = this.localClients.filter( (client) => client.clientId !== socket.id, ); - this.clientsSocketMap.delete(socket.id); + this.localClientsSocketMap.delete(socket.id); } catch (error) { this.logger.error(error); socket.disconnect(true); diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index abce9af..62ad902 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -1,3 +1,5 @@ +import { IWorker } from "@postgress-db/schema/workers"; + export interface GatewayClient { clientId: string; gatewayId: string; @@ -5,3 +7,5 @@ export interface GatewayClient { } export type GatewayClients = GatewayClient[]; + +export type GatewayWorker = Pick; From f8c46bc9fa2058bce27fc39eb8c6132277a8ef83 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Feb 2025 10:36:19 +0200 Subject: [PATCH 078/180] feat: timestamps and get workers method --- src/@socket/socket.gateway.ts | 37 +++++++++++++++++++ src/@socket/socket.service.ts | 4 ++ src/@socket/socket.types.ts | 5 ++- .../orders-socket-notifier.service.ts | 1 + 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index 7d2d3a4..b48749c 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -156,6 +156,39 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { return clients; } + /** + * Get all workers from all gateways + * @returns All workers + */ + public async getWorkers() { + const gatewayKeys = await this.publisherRedis.keys( + `${this.gatewayId}:workers`, + ); + + const workersRaw = await this.publisherRedis.mget(gatewayKeys); + + const workers: GatewayWorker[] = workersRaw + .filter(Boolean) + .map((worker) => JSON.parse(String(worker))); + + 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, + ); + } + /** * Handle a new connection * @param socket - The socket connection @@ -163,11 +196,13 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { 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); @@ -175,6 +210,8 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { this.localWorkersMap.set(worker.id, { id: worker.id, role: worker.role, + restaurantId: worker.restaurantId, + connectedAt, } satisfies GatewayWorker); socket.emit("connected", worker); diff --git a/src/@socket/socket.service.ts b/src/@socket/socket.service.ts index d0ea685..b6a3c25 100644 --- a/src/@socket/socket.service.ts +++ b/src/@socket/socket.service.ts @@ -12,4 +12,8 @@ export class SocketService { public async getClients() { return await this.socketGateway.getClients(); } + + public async getWorkers() { + return await this.socketGateway.getWorkers(); + } } diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index 62ad902..d5aca3c 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -4,8 +4,11 @@ export interface GatewayClient { clientId: string; gatewayId: string; workerId: string; + connectedAt: Date; } export type GatewayClients = GatewayClient[]; -export type GatewayWorker = Pick; +export type GatewayWorker = Pick & { + connectedAt: Date; +}; diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index 4f51cf3..ffcaad9 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -63,6 +63,7 @@ export class OrdersSocketNotifier { */ public async handle(order: OrderEntity) { const clients = await this.socketService.getClients(); + const workers = await this.socketService.getWorkers(); // const workersByRoleMap = this.makeWorkersByRoleMap(clients); console.log(clients); From 7d08e0842a4feb6613c139d064c2a09d0de9f370 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Feb 2025 11:27:23 +0200 Subject: [PATCH 079/180] feat: socket gateway emit to clients (without multi-node implementation) --- src/@socket/socket.gateway.ts | 49 ++++++++++++++++--- src/@socket/socket.service.ts | 22 +++++++-- src/@socket/socket.types.ts | 10 ++++ .../orders-socket-notifier.service.ts | 16 ++++-- 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index b48749c..46ed9c6 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -71,7 +71,7 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { this.publisherRedis = this._getRedis(); this.subscriberRedis = this._getRedis(); - this.subscriberRedis.subscribe(this.gatewayId); + this.subscriberRedis.subscribe(`${this.gatewayId}:messages`); this.subscriberRedis.on("message", (channel, message) => { this.logger.debug(channel, message); }); @@ -142,16 +142,16 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { * Get all clients from all gateways * @returns All clients */ - public async getClients() { + public async getClients(): Promise { const gatewayKeys = await this.publisherRedis.keys( `${this.gatewayId}:clients`, ); const clientsRaw = await this.publisherRedis.mget(gatewayKeys); - const clients: GatewayClients = clientsRaw + const clients: GatewayClient[] = clientsRaw .filter(Boolean) - .map((client) => JSON.parse(String(client))); + .flatMap((client) => JSON.parse(String(client))); return clients; } @@ -160,16 +160,22 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { * Get all workers from all gateways * @returns All workers */ - public async getWorkers() { + public async getWorkers(): Promise> { const gatewayKeys = await this.publisherRedis.keys( `${this.gatewayId}:workers`, ); const workersRaw = await this.publisherRedis.mget(gatewayKeys); - const workers: GatewayWorker[] = workersRaw - .filter(Boolean) - .map((worker) => JSON.parse(String(worker))); + const workers: GatewayWorker[] = Object.values( + workersRaw + .filter(Boolean) + .flatMap((workers) => + Object.values( + JSON.parse(String(workers)) as Record, + ), + ), + ); return workers.reduce( (acc, worker) => { @@ -189,6 +195,33 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { ); } + public async emit(recipients: GatewayClient[], event: string, data: any) { + this.logger.log(recipients, event, data); + recipients.forEach((recipient) => { + const localSocket = this.localClientsSocketMap.get(recipient.clientId); + + // If the client is local, emit the event to the client + if (localSocket) { + localSocket.emit(event, data); + } else { + if (recipient.gatewayId === this.gatewayId) { + this.logger.error( + `Event ${event} emmited to recipient ${recipient.clientId} but the recipient is not local`, + ); + } else { + // If the client is not local we should emit the event to the redis + this.publisherRedis.publish( + `${recipient.gatewayId}:messages`, + JSON.stringify({ + event, + data, + }), + ); + } + } + }); + } + /** * Handle a new connection * @param socket - The socket connection diff --git a/src/@socket/socket.service.ts b/src/@socket/socket.service.ts index b6a3c25..df115bf 100644 --- a/src/@socket/socket.service.ts +++ b/src/@socket/socket.service.ts @@ -1,9 +1,6 @@ -import env from "@core/env"; import { Injectable } from "@nestjs/common"; -import Redis from "ioredis"; -import { RedisChannels } from "src/@base/redis/channels"; import { SocketGateway } from "src/@socket/socket.gateway"; -import { v4 as uuidv4 } from "uuid"; +import { SocketEmitTo } from "src/@socket/socket.types"; @Injectable() export class SocketService { @@ -16,4 +13,21 @@ export class SocketService { public async getWorkers() { return await this.socketGateway.getWorkers(); } + + public async emit(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.socketGateway.emit(recipients, event, data); + } } diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index d5aca3c..2dfcfcb 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -12,3 +12,13 @@ export type GatewayClients = GatewayClient[]; export type GatewayWorker = Pick & { connectedAt: Date; }; + +export type SocketEmitTo = + | { + clientIds: string[]; + workerIds: undefined; + } + | { + clientIds: undefined; + workerIds: string[]; + }; diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index ffcaad9..6a7ecfc 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -62,10 +62,20 @@ export class OrdersSocketNotifier { * @param order */ public async handle(order: OrderEntity) { - const clients = await this.socketService.getClients(); + // const clients = await this.socketService.getClients(); const workers = await this.socketService.getWorkers(); - // const workersByRoleMap = this.makeWorkersByRoleMap(clients); - console.log(clients); + this.logger.log(workers); + // const workersByRoleMap = this.makeWorkersByRoleMap(clients); + await this.socketService.emit( + { + workerIds: Object.keys(workers), + clientIds: undefined, + }, + "order-updated", + { + orderId: order.id, + }, + ); } } From 9536382f0ecac94166899bd77a6a824d7a5d1db4 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Feb 2025 13:12:30 +0200 Subject: [PATCH 080/180] feat: multiple instances pub/sub socket support with pipeline optimization --- .env.test | 3 +- .example.env | 3 +- package.json | 3 +- src/@core/env.ts | 3 + src/@socket/socket.gateway.ts | 120 ++++++++++++++---- src/@socket/socket.types.ts | 6 + src/@socket/socket.utils.ts | 17 +++ src/auth/services/auth.service.ts | 24 ++++ .../orders-socket-notifier.service.ts | 1 - test/multiple-run/.dockerignore | 2 + test/multiple-run/Dockerfile | 29 +++++ test/multiple-run/docker-compose.yml | 34 +++++ test/socket.ts | 65 ++++++++++ yarn.lock | 2 +- 14 files changed, 284 insertions(+), 28 deletions(-) create mode 100644 test/multiple-run/.dockerignore create mode 100644 test/multiple-run/Dockerfile create mode 100644 test/multiple-run/docker-compose.yml create mode 100644 test/socket.ts diff --git a/.env.test b/.env.test index 7f641aa..3b58092 100644 --- a/.env.test +++ b/.env.test @@ -6,4 +6,5 @@ INITIAL_ADMIN_PASSWORD=123456 PORT=6701 DADATA_API_TOKEN= GOOGLE_MAPS_API_KEY= -REDIS_URL=redis://localhost:6379 \ No newline at end of file +REDIS_URL=redis://localhost:6379 +DEV_SECRET_KEY=123456 \ No newline at end of file diff --git a/.example.env b/.example.env index b23563e..60576fe 100644 --- a/.example.env +++ b/.example.env @@ -6,4 +6,5 @@ PORT=6701 DADATA_API_TOKEN= GOOGLE_MAPS_API_KEY= REDIS_URL= -API_SECRET_KEY= \ No newline at end of file +API_SECRET_KEY= +DEV_SECRET_KEY= \ No newline at end of file diff --git a/package.json b/package.json index 3ce202a..c49ae91 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", @@ -105,7 +106,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/@core/env.ts b/src/@core/env.ts index 4108664..a519031 100644 --- a/src/@core/env.ts +++ b/src/@core/env.ts @@ -57,6 +57,8 @@ export const envSchema = z.object({ ENDPOINT: z.string().url(), REGION: z.string(), }), + + DEV_SECRET_KEY: z.string(), }); const env = envSchema.parse({ @@ -81,6 +83,7 @@ const env = envSchema.parse({ 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/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index 46ed9c6..49749d8 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -12,6 +12,7 @@ import { RedisChannels } from "src/@base/redis/channels"; import { GatewayClient, GatewayClients, + GatewayMessage, GatewayWorker, } from "src/@socket/socket.types"; import { AuthService } from "src/auth/services/auth.service"; @@ -71,9 +72,9 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { this.publisherRedis = this._getRedis(); this.subscriberRedis = this._getRedis(); - this.subscriberRedis.subscribe(`${this.gatewayId}:messages`); + this.subscriberRedis.subscribe(`${this.gatewayId}-messages`); this.subscriberRedis.on("message", (channel, message) => { - this.logger.debug(channel, message); + this._handleMessage(JSON.parse(message) as GatewayMessage[]); }); await this._updateDiscovery(); @@ -83,6 +84,38 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { }, 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 */ @@ -104,12 +137,32 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { } } + 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); @@ -144,7 +197,7 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { */ public async getClients(): Promise { const gatewayKeys = await this.publisherRedis.keys( - `${this.gatewayId}:clients`, + `${SocketUtils.commonGatewaysIdentifier}:*:clients`, ); const clientsRaw = await this.publisherRedis.mget(gatewayKeys); @@ -162,7 +215,7 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { */ public async getWorkers(): Promise> { const gatewayKeys = await this.publisherRedis.keys( - `${this.gatewayId}:workers`, + `${SocketUtils.commonGatewaysIdentifier}:*:workers`, ); const workersRaw = await this.publisherRedis.mget(gatewayKeys); @@ -196,30 +249,51 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { } public async emit(recipients: GatewayClient[], event: string, data: any) { - this.logger.log(recipients, event, data); - recipients.forEach((recipient) => { - const localSocket = this.localClientsSocketMap.get(recipient.clientId); + // Group recipients by gateway for batch publishing + const recipientsByGateway = recipients.reduce( + (acc, recipient) => { + if (!acc[recipient.gatewayId]) { + acc[recipient.gatewayId] = []; + } + acc[recipient.gatewayId].push(recipient); + return acc; + }, + {} as Record, + ); - // If the client is local, emit the event to the client + // Handle local emissions + const localRecipients = recipientsByGateway[this.gatewayId] ?? []; + localRecipients.forEach((recipient) => { + const localSocket = this.localClientsSocketMap.get(recipient.clientId); if (localSocket) { localSocket.emit(event, data); - } else { - if (recipient.gatewayId === this.gatewayId) { - this.logger.error( - `Event ${event} emmited to recipient ${recipient.clientId} but the recipient is not local`, - ); - } else { - // If the client is not local we should emit the event to the redis - this.publisherRedis.publish( - `${recipient.gatewayId}:messages`, - JSON.stringify({ - event, - data, - }), - ); - } } }); + + // Create batched messages for each gateway + const pipeline = this.publisherRedis.pipeline(); + + Object.entries(recipientsByGateway).forEach( + ([gatewayId, gatewayRecipients]) => { + if (gatewayId === this.gatewayId) return; + + const messages: GatewayMessage[] = gatewayRecipients.map( + (recipient) => ({ + clientId: recipient.clientId, + event, + data, + }), + ); + + pipeline.publish(`${gatewayId}-messages`, JSON.stringify(messages)); + }, + ); + + try { + await pipeline.exec(); + } catch (error) { + this.logger.error("Error publishing messages:", error); + } } /** diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index 2dfcfcb..32709e8 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -13,6 +13,12 @@ export type GatewayWorker = Pick & { connectedAt: Date; }; +export type GatewayMessage = { + clientId: string; + event: string; + data: any; +}; + export type SocketEmitTo = | { clientIds: string[]; diff --git a/src/@socket/socket.utils.ts b/src/@socket/socket.utils.ts index c187f4a..d90018e 100644 --- a/src/@socket/socket.utils.ts +++ b/src/@socket/socket.utils.ts @@ -40,4 +40,21 @@ export class SocketUtils { 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/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 08cc5f0..045a1e7 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,7 +1,9 @@ import { UnauthorizedException } from "@core/errors/exceptions/unauthorized.exception"; 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 { 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"; @@ -62,4 +64,26 @@ export class AuthService { ) { return this.sessionsService.validateSession(signed, options); } + + public async getAuthWorker(workerId: string) { + const [worker] = await this.pg + .select({ + id: workers.id, + name: workers.name, + login: workers.login, + role: workers.role, + isBlocked: workers.isBlocked, + hiredAt: workers.hiredAt, + firedAt: workers.firedAt, + onlineAt: workers.onlineAt, + createdAt: workers.createdAt, + updatedAt: workers.updatedAt, + restaurantId: workers.restaurantId, + }) + .from(workers) + .where(eq(workers.id, workerId)) + .limit(1); + + return worker; + } } diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index 6a7ecfc..677d202 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -65,7 +65,6 @@ export class OrdersSocketNotifier { // const clients = await this.socketService.getClients(); const workers = await this.socketService.getWorkers(); - this.logger.log(workers); // const workersByRoleMap = this.makeWorkersByRoleMap(clients); await this.socketService.emit( { 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/yarn.lock b/yarn.lock index 1e3da49..ccfc0dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7854,7 +7854,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== From 08338b19fefffdc8885a698247d11d5391555338 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Feb 2025 16:55:41 +0200 Subject: [PATCH 081/180] feat: subscriptions for syncing data on frontend --- src/@socket/socket.gateway.ts | 109 +++++++++++++++++- src/@socket/socket.service.ts | 8 +- src/@socket/socket.types.ts | 28 +++++ src/orders/@/services/orders.service.ts | 1 + .../orders-socket-notifier.service.ts | 101 ++++++++-------- 5 files changed, 190 insertions(+), 57 deletions(-) diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index 49749d8..19281c5 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -1,8 +1,11 @@ import env from "@core/env"; import { Logger } from "@nestjs/common"; import { + ConnectedSocket, + MessageBody, OnGatewayConnection, OnGatewayDisconnect, + SubscribeMessage, WebSocketGateway, WebSocketServer, } from "@nestjs/websockets"; @@ -12,8 +15,11 @@ import { RedisChannels } from "src/@base/redis/channels"; import { GatewayClient, GatewayClients, + GatewayClientSubscription, + GatewayIncomingMessage, GatewayMessage, GatewayWorker, + IncomingSubscription, } from "src/@socket/socket.types"; import { AuthService } from "src/auth/services/auth.service"; @@ -42,6 +48,8 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { 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(); @@ -121,17 +129,29 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { */ private async _updateDiscovery() { try { - await this.publisherRedis.setex( + const pipeline = this.publisherRedis.pipeline(); + + pipeline.setex( `${this.gatewayId}:clients`, this.REDIS_CLIENTS_TTL, JSON.stringify(this.localClients), ); - await this.publisherRedis.setex( + 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); } @@ -248,6 +268,31 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { ); } + /** + * 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(recipients: GatewayClient[], event: string, data: any) { // Group recipients by gateway for batch publishing const recipientsByGateway = recipients.reduce( @@ -339,9 +384,69 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { ); 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 "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.service.ts b/src/@socket/socket.service.ts index df115bf..872e19e 100644 --- a/src/@socket/socket.service.ts +++ b/src/@socket/socket.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { SocketGateway } from "src/@socket/socket.gateway"; -import { SocketEmitTo } from "src/@socket/socket.types"; +import { GatewayClient, SocketEmitTo } from "src/@socket/socket.types"; @Injectable() export class SocketService { @@ -14,7 +14,11 @@ export class SocketService { return await this.socketGateway.getWorkers(); } - public async emit(to: SocketEmitTo, event: string, data: any) { + public async emit(recipients: GatewayClient[], event: string, data: any) { + return await this.socketGateway.emit(recipients, event, data); + } + + public async emitTo(to: SocketEmitTo, event: string, data: any) { const clients = await this.getClients(); const findClientIdsSet = new Set(to.clientIds); diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index 32709e8..cda8e59 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -1,5 +1,33 @@ import { IWorker } from "@postgress-db/schema/workers"; +export enum GatewayIncomingMessage { + SUBSCRIPTION = "subscription", +} + +export enum ClientSubscriptionType { + ORDER = "ORDER", +} + +export interface ClientOrderSubscription { + orderId: string; +} + +export type GatewayClientSubscription = { + id: string; + clientId: string; + type: `${ClientSubscriptionType.ORDER}`; + data: ClientOrderSubscription; +}; + +export enum IncomingSubscriptionAction { + SUBSCRIBE = "subscribe", + UNSUBSCRIBE = "unsubscribe", +} + +export type IncomingSubscription = GatewayClientSubscription & { + action: `${IncomingSubscriptionAction}`; +}; + export interface GatewayClient { clientId: string; gatewayId: string; diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 08a672b..a85f851 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -182,6 +182,7 @@ export class OrdersService { if (!order) { throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); } + const { tableNumber, restaurantId, diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index 677d202..ddbc1e1 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -1,9 +1,12 @@ import { Injectable, Logger } from "@nestjs/common"; -import { IRole } from "@postgress-db/schema/workers"; +import { plainToClass } from "class-transformer"; import { SocketService } from "src/@socket/socket.service"; +import { GatewayWorker } from "src/@socket/socket.types"; import { OrderEntity } from "src/orders/@/entities/order.entity"; -type workerId = string; +export enum OrderSocketEvents { + ORDER_UPDATE = "order:update", +} @Injectable() export class OrdersSocketNotifier { @@ -11,49 +14,44 @@ export class OrdersSocketNotifier { constructor(private readonly socketService: SocketService) {} - // private makeWorkersByRoleMap(clients: RedisConnectedClients) { - // const workersByRoleMap: Record< - // IRole, - // Record> - // > = { - // SYSTEM_ADMIN: {}, - // CHIEF_ADMIN: {}, - // ADMIN: {}, - // KITCHENER: {}, - // WAITER: {}, - // CASHIER: {}, - // DISPATCHER: {}, - // COURIER: {}, - // }; - - // Object.entries(clients).forEach(([, clientObject]) => { - // // WTF user connected with no clients? - // if (Object.keys(clientObject).length === 0) { - // return; - // } - - // const [, client] = Object.entries(clientObject)[0]; - - // const { worker, session } = client; - - // if (!workersByRoleMap?.[worker.role]) { - // workersByRoleMap[worker.role] = {}; + // private _getSharedRecipients( + // workers: GatewayWorker[], + // restaurantId?: string | null, + // ) { + // return workers.filter((worker) => { + // if ( + // worker.role === "SYSTEM_ADMIN" || + // worker.role === "CHIEF_ADMIN" || + // (restaurantId && + // worker.role === "ADMIN" && + // worker.restaurantId === restaurantId) || + // (worker.role === "DISPATCHER" && !worker.restaurantId) + // ) { + // return true; // } - - // // if worker already exists, skip - // if (!!workersByRoleMap[worker.role]?.[worker.id]) { - // return; - // } - - // workersByRoleMap[worker.role][worker.id] = { - // clientId: client.clientId, - // gatewayId: client.gatewayId, - // worker, - // session, - // }; // }); + // } + + // private async _notifyDefaultOrder( + // order: OrderEntity, + // workers: GatewayWorker[], + // ) { + // const recipientWorkers = [ + // ...this._getSharedRecipients(workers, order.restaurantId), + // ]; - // return workersByRoleMap; + // this.socketService.emitTo( + // { + // workerIds: recipientWorkers.map((worker) => worker.id), + // clientIds: undefined, + // }, + // OrderSocketEvents.ORDER_UPDATE, + // { + // order: plainToClass(OrderEntity, order, { + // excludeExtraneousValues: true, + // }), + // }, + // ); // } /** @@ -62,19 +60,16 @@ export class OrdersSocketNotifier { * @param order */ public async handle(order: OrderEntity) { - // const clients = await this.socketService.getClients(); const workers = await this.socketService.getWorkers(); + // await this._notifyDefaultOrder(order, Object.values(workers)); + // const workersByRoleMap = this.makeWorkersByRoleMap(clients); - await this.socketService.emit( - { - workerIds: Object.keys(workers), - clientIds: undefined, - }, - "order-updated", - { - orderId: order.id, - }, - ); + // await this.socketService.emit( + // { + // workerIds: Object.keys(workers), + // clientIds: undefined, + // }, + // ); } } From 0f960b84ee1f129a8e2110b5d5b1966cebbb413f Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Feb 2025 17:27:28 +0200 Subject: [PATCH 082/180] feat: emit different data to recipients implementation --- src/@socket/socket.gateway.ts | 44 +++++------ src/@socket/socket.service.ts | 26 ++++++- .../orders-socket-notifier.service.ts | 74 ++++++------------- 3 files changed, 66 insertions(+), 78 deletions(-) diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index 19281c5..afe43f0 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -293,46 +293,42 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { return subscriptions; } - public async emit(recipients: GatewayClient[], event: string, data: any) { - // Group recipients by gateway for batch publishing - const recipientsByGateway = recipients.reduce( - (acc, recipient) => { + 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(recipient); + acc[recipient.gatewayId].push({ + clientId: recipient.clientId, + event, + data, + }); return acc; }, - {} as Record, + {} as Record, ); // Handle local emissions - const localRecipients = recipientsByGateway[this.gatewayId] ?? []; - localRecipients.forEach((recipient) => { - const localSocket = this.localClientsSocketMap.get(recipient.clientId); + const localMessages = messagesByGateway[this.gatewayId] ?? []; + localMessages.forEach((message) => { + const localSocket = this.localClientsSocketMap.get(message.clientId); if (localSocket) { - localSocket.emit(event, data); + localSocket.emit(message.event, message.data); } }); // Create batched messages for each gateway const pipeline = this.publisherRedis.pipeline(); - Object.entries(recipientsByGateway).forEach( - ([gatewayId, gatewayRecipients]) => { - if (gatewayId === this.gatewayId) return; - - const messages: GatewayMessage[] = gatewayRecipients.map( - (recipient) => ({ - clientId: recipient.clientId, - event, - data, - }), - ); + Object.entries(messagesByGateway).forEach(([gatewayId, messages]) => { + if (gatewayId === this.gatewayId) return; - pipeline.publish(`${gatewayId}-messages`, JSON.stringify(messages)); - }, - ); + pipeline.publish(`${gatewayId}-messages`, JSON.stringify(messages)); + }); try { await pipeline.exec(); diff --git a/src/@socket/socket.service.ts b/src/@socket/socket.service.ts index 872e19e..aeaa688 100644 --- a/src/@socket/socket.service.ts +++ b/src/@socket/socket.service.ts @@ -14,8 +14,28 @@ export class SocketService { return await this.socketGateway.getWorkers(); } - public async emit(recipients: GatewayClient[], event: string, data: any) { - return await this.socketGateway.emit(recipients, event, data); + 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) { @@ -32,6 +52,6 @@ export class SocketService { return false; }); - return await this.socketGateway.emit(recipients, event, data); + return await this.emitToMultiple(recipients, event, data); } } diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index ddbc1e1..5f4873f 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -5,7 +5,7 @@ import { GatewayWorker } from "src/@socket/socket.types"; import { OrderEntity } from "src/orders/@/entities/order.entity"; export enum OrderSocketEvents { - ORDER_UPDATE = "order:update", + SUBSCRIPTION_UPDATED = "subscription:update", } @Injectable() @@ -14,46 +14,6 @@ export class OrdersSocketNotifier { constructor(private readonly socketService: SocketService) {} - // private _getSharedRecipients( - // workers: GatewayWorker[], - // restaurantId?: string | null, - // ) { - // return workers.filter((worker) => { - // if ( - // worker.role === "SYSTEM_ADMIN" || - // worker.role === "CHIEF_ADMIN" || - // (restaurantId && - // worker.role === "ADMIN" && - // worker.restaurantId === restaurantId) || - // (worker.role === "DISPATCHER" && !worker.restaurantId) - // ) { - // return true; - // } - // }); - // } - - // private async _notifyDefaultOrder( - // order: OrderEntity, - // workers: GatewayWorker[], - // ) { - // const recipientWorkers = [ - // ...this._getSharedRecipients(workers, order.restaurantId), - // ]; - - // this.socketService.emitTo( - // { - // workerIds: recipientWorkers.map((worker) => worker.id), - // clientIds: undefined, - // }, - // OrderSocketEvents.ORDER_UPDATE, - // { - // order: plainToClass(OrderEntity, order, { - // excludeExtraneousValues: true, - // }), - // }, - // ); - // } - /** * ! WE SHOULD NOTIFY USERS ONLY IF ORDER HAVE CHANGED DATA * ! (needs to be implemented before calling that method) @@ -61,15 +21,27 @@ export class OrdersSocketNotifier { */ public async handle(order: OrderEntity) { const workers = await this.socketService.getWorkers(); - - // await this._notifyDefaultOrder(order, Object.values(workers)); - - // const workersByRoleMap = this.makeWorkersByRoleMap(clients); - // await this.socketService.emit( - // { - // workerIds: Object.keys(workers), - // clientIds: undefined, - // }, - // ); + const subscriptions = await this.socketService.getSubscriptions(); + + const orderSubscriptions = subscriptions.filter((subscription) => { + return ( + subscription.type === "ORDER" && subscription.data.orderId === order.id + ); + }); + + const clientIds = orderSubscriptions.map( + (subscription) => subscription.clientId, + ); + + await this.socketService.emitTo( + { + clientIds, + workerIds: undefined, + }, + OrderSocketEvents.SUBSCRIPTION_UPDATED, + { + type: "ORDER", + }, + ); } } From e3f73938cf3a43925996fbe7a0dc101a66c3b33d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Feb 2025 17:37:00 +0200 Subject: [PATCH 083/180] feat: sending data to users about subscription update --- src/@socket/socket.types.ts | 16 +++++ .../orders-socket-notifier.service.ts | 61 +++++++++++-------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index cda8e59..a14092a 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -1,4 +1,5 @@ import { IWorker } from "@postgress-db/schema/workers"; +import { OrderEntity } from "src/orders/@/entities/order.entity"; export enum GatewayIncomingMessage { SUBSCRIPTION = "subscription", @@ -56,3 +57,18 @@ export type SocketEmitTo = 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/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index 5f4873f..857839b 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -1,13 +1,13 @@ import { Injectable, Logger } from "@nestjs/common"; import { plainToClass } from "class-transformer"; import { SocketService } from "src/@socket/socket.service"; -import { GatewayWorker } from "src/@socket/socket.types"; +import { + GatewayClient, + SocketEventType, + SocketOrderUpdateEvent, +} from "src/@socket/socket.types"; import { OrderEntity } from "src/orders/@/entities/order.entity"; -export enum OrderSocketEvents { - SUBSCRIPTION_UPDATED = "subscription:update", -} - @Injectable() export class OrdersSocketNotifier { private readonly logger = new Logger(OrdersSocketNotifier.name); @@ -20,28 +20,41 @@ export class OrdersSocketNotifier { * @param order */ public async handle(order: OrderEntity) { - const workers = await this.socketService.getWorkers(); + const clients = await this.socketService.getClients(); const subscriptions = await this.socketService.getSubscriptions(); - const orderSubscriptions = subscriptions.filter((subscription) => { - return ( - subscription.type === "ORDER" && subscription.data.orderId === order.id - ); - }); - - const clientIds = orderSubscriptions.map( - (subscription) => subscription.clientId, + const clientsMap = new Map( + clients.map((client) => [client.clientId, client]), ); - await this.socketService.emitTo( - { - clientIds, - workerIds: undefined, - }, - OrderSocketEvents.SUBSCRIPTION_UPDATED, - { - type: "ORDER", - }, - ); + const messages: { + recipient: GatewayClient; + event: string; + data: any; + }[] = []; + + subscriptions.forEach((subscription) => { + if ( + subscription.type === "ORDER" && + subscription.data.orderId === 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); } } From 296ace62d47a1afa0e5798b171dd3beebe842a0b Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 11 Feb 2025 15:02:05 +0200 Subject: [PATCH 084/180] feat: audit logs for requests with mongodb --- .../audit-logs/audit-logs.interceptor.ts | 149 ++++++++++++++++++ src/@base/audit-logs/audit-logs.module.ts | 18 +++ src/@base/audit-logs/audit-logs.service.ts | 23 +++ .../decorators/audit-logs.decorator.ts | 15 ++ .../audit-logs/schemas/audit-log.schema.ts | 67 ++++++++ src/@core/interfaces/request.ts | 5 + src/app.module.ts | 9 +- src/auth/controllers/auth.controller.ts | 2 + src/workers/workers.controller.ts | 5 + 9 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 src/@base/audit-logs/audit-logs.interceptor.ts create mode 100644 src/@base/audit-logs/audit-logs.module.ts create mode 100644 src/@base/audit-logs/audit-logs.service.ts create mode 100644 src/@base/audit-logs/decorators/audit-logs.decorator.ts create mode 100644 src/@base/audit-logs/schemas/audit-log.schema.ts 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..4bf070e --- /dev/null +++ b/src/@base/audit-logs/audit-logs.interceptor.ts @@ -0,0 +1,149 @@ +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 { AuditLogsService } from "./audit-logs.service"; +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 auditLogsService: AuditLogsService, + ) {} + + 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 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 request = context.switchToHttp().getRequest(); + 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.auditLogsService.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.auditLogsService.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..7c09d81 --- /dev/null +++ b/src/@base/audit-logs/audit-logs.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; + +import { AuditLogsInterceptor } from "./audit-logs.interceptor"; +import { AuditLogsService } from "./audit-logs.service"; +import { AuditLog, AuditLogSchema } from "./schemas/audit-log.schema"; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: AuditLog.name, schema: AuditLogSchema }, + ]), + ], + controllers: [], + providers: [AuditLogsService, AuditLogsInterceptor], + exports: [AuditLogsService], +}) +export class AuditLogsModule {} 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..1eb8dc8 --- /dev/null +++ b/src/@base/audit-logs/schemas/audit-log.schema.ts @@ -0,0 +1,67 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Document } from "mongoose"; + +export type AuditLogDocument = AuditLog & Document; + +@Schema({ timestamps: true }) +export class AuditLog { + @Prop({ required: true }) + method: string; + + @Prop({ required: true }) + url: string; + + @Prop({ type: Object }) + params: Record; + + @Prop({ type: Object }) + query: Record; + + @Prop({ type: Object }) + body: Record; + + @Prop() + userAgent: string; + + @Prop({ required: true }) + ipAddress: string; + + @Prop() + userId: string; + + @Prop({ type: Object }) + response: Record; + + @Prop({ type: Object }) + error: Record; + + @Prop({ default: false }) + isFailed: boolean; + + @Prop() + statusCode: number; + + @Prop() + duration: number; // Request duration in milliseconds + + @Prop() + requestId: string; // Unique identifier for the request + + @Prop({ type: Object }) + headers: Record; + + @Prop() + origin: string; // Request origin/referer + + @Prop({ required: false }) + sessionId?: string; + + @Prop({ required: false }) + workerId?: string; + + // Automatically managed by timestamps option + createdAt: Date; + updatedAt: Date; +} + +export const AuditLogSchema = SchemaFactory.createForClass(AuditLog); diff --git a/src/@core/interfaces/request.ts b/src/@core/interfaces/request.ts index 25eef6d..feb5cab 100644 --- a/src/@core/interfaces/request.ts +++ b/src/@core/interfaces/request.ts @@ -25,6 +25,11 @@ export type RequestSession = Pick< }; export interface Request extends Req { + requestId?: string; worker?: RequestWorker | null; session?: RequestSession | null; + user?: { + id: string; + [key: string]: any; + }; } diff --git a/src/app.module.ts b/src/app.module.ts index 0d28564..49a73c8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,7 +6,7 @@ import { RedisModule } from "@liaoliaots/nestjs-redis"; import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; -import { 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"; @@ -17,6 +17,8 @@ import { 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 { S3Module } from "src/@base/s3/s3.module"; @@ -47,6 +49,7 @@ import { WorkersModule } from "./workers/workers.module"; ], }), DrizzleModule, + AuditLogsModule, MongooseModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -105,6 +108,10 @@ import { WorkersModule } from "./workers/workers.module"; provide: APP_GUARD, useClass: RolesGuard, }, + { + provide: APP_INTERCEPTOR, + useClass: AuditLogsInterceptor, + }, ], }) export class AppModule {} diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 340ea04..d670eee 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -21,6 +21,7 @@ import { ApiUnauthorizedResponse, } from "@nestjs/swagger"; import { IWorker } from "@postgress-db/schema/workers"; +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"; @@ -51,6 +52,7 @@ export class AuthController { } @Public() + @EnableAuditLog() @Post("sign-in") @HttpCode(HttpStatus.OK) @Serializable(WorkerEntity) diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index db5869c..928d3e2 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -27,6 +27,7 @@ import { 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"; @@ -39,6 +40,7 @@ import { WorkersService } from "./workers.service"; export class WorkersController { constructor(private readonly workersService: WorkersService) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @ApiOperation({ summary: "Gets workers that created in system" }) @Serializable(WorkersPaginatedDto) @@ -79,6 +81,7 @@ export class WorkersController { } // TODO: add validation of ADMIN restaurant id + @EnableAuditLog({ onlyErrors: true }) @Post() @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN", "ADMIN") @Serializable(WorkerEntity) @@ -113,6 +116,7 @@ export class WorkersController { return await this.workersService.create(data); } + @EnableAuditLog({ onlyErrors: true }) @Get(":id") @Serializable(WorkerEntity) @ApiOperation({ summary: "Gets a worker by id" }) @@ -143,6 +147,7 @@ export class WorkersController { } // TODO: add validation of ADMIN restaurant id + @EnableAuditLog() @Put(":id") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN", "ADMIN") @Serializable(WorkerEntity) From 8bf9e12f009f3448335e4bc3b15d3e74bda137c0 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 11 Feb 2025 15:24:18 +0200 Subject: [PATCH 085/180] feat: requestId in http exception handler --- src/@base/audit-logs/audit-logs.interceptor.ts | 6 +++++- src/@core/errors/http-exception-filter.ts | 6 ++++-- src/@core/interfaces/request.ts | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/@base/audit-logs/audit-logs.interceptor.ts b/src/@base/audit-logs/audit-logs.interceptor.ts index 4bf070e..f1aa170 100644 --- a/src/@base/audit-logs/audit-logs.interceptor.ts +++ b/src/@base/audit-logs/audit-logs.interceptor.ts @@ -53,6 +53,11 @@ export class AuditLogsInterceptor implements NestInterceptor { } 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(), @@ -68,7 +73,6 @@ export class AuditLogsInterceptor implements NestInterceptor { context.getHandler(), ) ?? {}; - const request = context.switchToHttp().getRequest(); const startTime = Date.now(); request.requestId = request?.requestId ?? uuidv4(); diff --git a/src/@core/errors/http-exception-filter.ts b/src/@core/errors/http-exception-filter.ts index 403b738..30a172d 100644 --- a/src/@core/errors/http-exception-filter.ts +++ b/src/@core/errors/http-exception-filter.ts @@ -1,5 +1,6 @@ // import { ValidationError } from "@i18n-class-validator"; import { ErrorInstance } from "@core/errors/index.types"; +import { Request } from "@core/interfaces/request"; import { ArgumentsHost, Catch, @@ -60,11 +61,11 @@ export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); - const request = ctx.getRequest(); + const request = ctx.getRequest() as Request; const response = ctx.getResponse(); const statusCode = exception.getStatus(); - const timestamp = new Date().getTime(); + const timestamp = request.timestamp ?? new Date().getTime(); const error = this.getError(exception); const validationErrors = [ @@ -76,6 +77,7 @@ export class HttpExceptionFilter implements ExceptionFilter { response.status(statusCode).json({ statusCode, + ...(request?.requestId ? { requestId: request.requestId } : {}), path: request.url, timestamp, message, diff --git a/src/@core/interfaces/request.ts b/src/@core/interfaces/request.ts index feb5cab..419d2a9 100644 --- a/src/@core/interfaces/request.ts +++ b/src/@core/interfaces/request.ts @@ -26,6 +26,7 @@ export type RequestSession = Pick< export interface Request extends Req { requestId?: string; + timestamp?: number; worker?: RequestWorker | null; session?: RequestSession | null; user?: { From 4dbb09d7f187e86aacbbc73aa8794217b8f07ddb Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Feb 2025 17:40:42 +0200 Subject: [PATCH 086/180] feat: snapshots with enabling them for orders --- .../snapshots/schemas/snapshot.schema.ts | 51 +++++++++++++ src/@base/snapshots/snapshots.module.ts | 19 +++++ src/@base/snapshots/snapshots.service.ts | 76 +++++++++++++++++++ src/@base/snapshots/types/index.ts | 14 ++++ src/app.module.ts | 2 + src/orders/@queue/dto/crud-update.job.ts | 2 +- src/orders/@queue/orders-queue.module.ts | 2 + src/orders/@queue/orders-queue.processor.ts | 14 +++- 8 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/@base/snapshots/schemas/snapshot.schema.ts create mode 100644 src/@base/snapshots/snapshots.module.ts create mode 100644 src/@base/snapshots/snapshots.service.ts create mode 100644 src/@base/snapshots/types/index.ts diff --git a/src/@base/snapshots/schemas/snapshot.schema.ts b/src/@base/snapshots/schemas/snapshot.schema.ts new file mode 100644 index 0000000..7bfedbd --- /dev/null +++ b/src/@base/snapshots/schemas/snapshot.schema.ts @@ -0,0 +1,51 @@ +import { CrudAction } from "@core/types/general"; +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { IWorker } from "@postgress-db/schema/workers"; + +import { 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; + + 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..c4dd617 --- /dev/null +++ b/src/@base/snapshots/snapshots.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; + +import { Snapshot, SnapshotSchema } from "./schemas/snapshot.schema"; +import { SnapshotsService } from "./snapshots.service"; + +@Module({ + imports: [ + DrizzleModule, + MongooseModule.forFeature([ + { name: Snapshot.name, schema: SnapshotSchema }, + ]), + ], + controllers: [], + providers: [SnapshotsService], + exports: [SnapshotsService], +}) +export class SnapshotsModule {} diff --git a/src/@base/snapshots/snapshots.service.ts b/src/@base/snapshots/snapshots.service.ts new file mode 100644 index 0000000..d88de1f --- /dev/null +++ b/src/@base/snapshots/snapshots.service.ts @@ -0,0 +1,76 @@ +import { CrudAction } from "@core/types/general"; +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 } 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, + ) {} + + private async determinateAction(payload: CreateSnapshotPayload) { + const { documentId, model, data } = payload; + + if (data === null) return CrudAction.DELETE; + + const document = await this.snapshotModel.findOne({ + documentId, + model, + }); + + if (document) return CrudAction.UPDATE; + + return CrudAction.CREATE; + } + + private async getWorker(workerId?: string | null) { + if (!workerId) return null; + + const worker = await this.pg.query.workers.findFirst({ + where: eq(schema.workers.id, workerId), + columns: { + id: true, + name: true, + login: true, + role: true, + restaurantId: true, + createdAt: true, + updatedAt: true, + firedAt: true, + hiredAt: true, + isBlocked: true, + onlineAt: true, + }, + }); + + return worker ?? null; + } + + async create(payload: CreateSnapshotPayload) { + const { model, data, documentId, workerId } = payload; + + const action = payload?.action ?? (await this.determinateAction(payload)); + const worker = await this.getWorker(workerId); + + const snapshot = new this.snapshotModel({ + model, + data, + documentId, + action, + worker, + workerId: workerId ?? null, + }); + + return await snapshot.save(); + } +} diff --git a/src/@base/snapshots/types/index.ts b/src/@base/snapshots/types/index.ts new file mode 100644 index 0000000..98a9c40 --- /dev/null +++ b/src/@base/snapshots/types/index.ts @@ -0,0 +1,14 @@ +import { CrudAction } from "@core/types/general"; + +export enum SnapshotModel { + RESTAURANTS = "RESTAURANTS", + ORDERS = "ORDERS", +} + +export type CreateSnapshotPayload = { + documentId: string; + model: `${SnapshotModel}`; + action?: `${CrudAction}`; + data: any; + workerId?: string | null; +}; diff --git a/src/app.module.ts b/src/app.module.ts index 49a73c8..d88df41 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ 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 { 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 { DishCategoriesModule } from "src/dish-categories/dish-categories.module"; @@ -49,6 +50,7 @@ import { WorkersModule } from "./workers/workers.module"; ], }), DrizzleModule, + SnapshotsModule, AuditLogsModule, MongooseModule.forRootAsync({ imports: [ConfigModule], diff --git a/src/orders/@queue/dto/crud-update.job.ts b/src/orders/@queue/dto/crud-update.job.ts index c0396b0..749a1fb 100644 --- a/src/orders/@queue/dto/crud-update.job.ts +++ b/src/orders/@queue/dto/crud-update.job.ts @@ -5,5 +5,5 @@ export class OrderCrudUpdateJobDto { orderId: string; order: OrderEntity; action: `${CrudAction}`; - calledByUserId?: string; + calledByWorkerId?: string; } diff --git a/src/orders/@queue/orders-queue.module.ts b/src/orders/@queue/orders-queue.module.ts index c824f19..7e007e2 100644 --- a/src/orders/@queue/orders-queue.module.ts +++ b/src/orders/@queue/orders-queue.module.ts @@ -1,6 +1,7 @@ import { BullModule } from "@nestjs/bullmq"; import { 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"; @@ -11,6 +12,7 @@ import { OrdersSocketNotifier } from "src/orders/@queue/services/orders-socket-n imports: [ SocketModule, DrizzleModule, + SnapshotsModule, BullModule.registerQueue({ name: ORDERS_QUEUE, }), diff --git a/src/orders/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts index 0b52186..cfa1412 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -1,10 +1,9 @@ import { Processor, WorkerHost } from "@nestjs/bullmq"; import { Inject, Logger } from "@nestjs/common"; import { Schema } from "@postgress-db/drizzle.module"; -import { orders } from "@postgress-db/schema/orders"; import { Job } from "bullmq"; -import { eq } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { SnapshotsService } from "src/@base/snapshots/snapshots.service"; import { PG_CONNECTION } from "src/constants"; import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; import { OrderCrudUpdateJobDto } from "src/orders/@queue/dto/crud-update.job"; @@ -19,6 +18,7 @@ export class OrdersQueueProcessor extends WorkerHost { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, private readonly ordersSocketNotifier: OrdersSocketNotifier, + private readonly snapshotsService: SnapshotsService, ) { super(); } @@ -62,7 +62,15 @@ export class OrdersQueueProcessor extends WorkerHost { } private async crudUpdate(data: OrderCrudUpdateJobDto) { - // log + // make snapshot + await this.snapshotsService.create({ + model: "ORDERS", + action: data.action, + data: data.order, + documentId: data.orderId, + workerId: data.calledByWorkerId, + }); + // notify users await this.ordersSocketNotifier.handle(data.order); } From 7ff51fb5b6b2c9dbacfca941b3a9beb2a1ff8299 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Feb 2025 17:47:51 +0200 Subject: [PATCH 087/180] feat: order update passing workerId to worker snapshots --- src/@base/snapshots/snapshots.service.ts | 18 +++++++++++++++++- src/orders/@/orders.controller.ts | 12 ++++++++++-- src/orders/@/services/orders.service.ts | 7 ++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/@base/snapshots/snapshots.service.ts b/src/@base/snapshots/snapshots.service.ts index d88de1f..c62a35d 100644 --- a/src/@base/snapshots/snapshots.service.ts +++ b/src/@base/snapshots/snapshots.service.ts @@ -18,9 +18,15 @@ export class SnapshotsService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} + /** + * Determines the action to be taken based on the payload + * @param payload Payload to determine the action + * @returns Action to be taken + */ private async determinateAction(payload: CreateSnapshotPayload) { const { documentId, model, data } = payload; + if (payload.action) return payload.action; if (data === null) return CrudAction.DELETE; const document = await this.snapshotModel.findOne({ @@ -33,6 +39,11 @@ export class SnapshotsService { return CrudAction.CREATE; } + /** + * Gets worker by id + * @param workerId ID of the worker + * @returns Worker or null if worker is not found + */ private async getWorker(workerId?: string | null) { if (!workerId) return null; @@ -56,10 +67,15 @@ export class SnapshotsService { return worker ?? null; } + /** + * Creates a snapshot + * @param payload Payload to create the snapshot + * @returns Created snapshot + */ async create(payload: CreateSnapshotPayload) { const { model, data, documentId, workerId } = payload; - const action = payload?.action ?? (await this.determinateAction(payload)); + const action = await this.determinateAction(payload); const worker = await this.getWorker(workerId); const snapshot = new this.snapshotModel({ diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index f849c30..f09ed0a 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -1,6 +1,8 @@ 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, Delete, Get, Param, Patch, Post } from "@nestjs/common"; import { ApiBadRequestResponse, @@ -69,8 +71,14 @@ export class OrdersController { description: "Order with this id doesn't exist", }) @ApiBadRequestResponse() - async update(@Param("id") id: string, @Body() dto: UpdateOrderDto) { - return this.ordersService.update(id, dto); + async update( + @Param("id") id: string, + @Body() dto: UpdateOrderDto, + @Worker() worker: RequestWorker, + ) { + return this.ordersService.update(id, dto, { + workerId: worker.id, + }); } @Post(":id/dishes") diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index a85f851..b53a468 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -166,7 +166,11 @@ export class OrdersService { return order; } - async update(id: string, dto: UpdateOrderDto): Promise { + async update( + id: string, + dto: UpdateOrderDto, + opts?: { workerId?: string }, + ): Promise { await this.checkDto(dto, id); const order = await this.pg.query.orders.findFirst({ @@ -226,6 +230,7 @@ export class OrdersService { action: "UPDATE", orderId: id, order: updatedOrderEntity, + calledByWorkerId: opts?.workerId, }); return updatedOrderEntity; From e85b55b154b2473b885ac56e3df8b80e3a854888 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Feb 2025 17:55:31 +0200 Subject: [PATCH 088/180] feat: changes field for schema for tracking what changed --- .../snapshots/schemas/snapshot.schema.ts | 8 ++- src/@base/snapshots/snapshots.service.ts | 61 +++++++++++++----- src/@base/snapshots/types/index.ts | 6 ++ src/@core/utils/deep-compare.ts | 63 +++++++++++++++++++ 4 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 src/@core/utils/deep-compare.ts diff --git a/src/@base/snapshots/schemas/snapshot.schema.ts b/src/@base/snapshots/schemas/snapshot.schema.ts index 7bfedbd..1487c0f 100644 --- a/src/@base/snapshots/schemas/snapshot.schema.ts +++ b/src/@base/snapshots/schemas/snapshot.schema.ts @@ -2,7 +2,7 @@ import { CrudAction } from "@core/types/general"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { IWorker } from "@postgress-db/schema/workers"; -import { SnapshotModel } from "../types"; +import { SnapshotChange, SnapshotModel } from "../types"; export type SnapshotDocument = Snapshot & Document; @@ -44,6 +44,12 @@ export class Snapshot { @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; } diff --git a/src/@base/snapshots/snapshots.service.ts b/src/@base/snapshots/snapshots.service.ts index c62a35d..89f1c41 100644 --- a/src/@base/snapshots/snapshots.service.ts +++ b/src/@base/snapshots/snapshots.service.ts @@ -1,11 +1,15 @@ 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 } from "src/@base/snapshots/types"; +import { + CreateSnapshotPayload, + SnapshotChange, +} from "src/@base/snapshots/types"; import { PG_CONNECTION } from "src/constants"; import { Snapshot, SnapshotDocument } from "./schemas/snapshot.schema"; @@ -19,24 +23,46 @@ export class SnapshotsService { ) {} /** - * Determines the action to be taken based on the payload - * @param payload Payload to determine the action - * @returns Action to be taken + * Determines the action to be taken based on the payload and previous snapshot */ - private async determinateAction(payload: CreateSnapshotPayload) { - const { documentId, model, data } = payload; + private async 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; - const document = await this.snapshotModel.findOne({ - documentId, - model, - }); + return CrudAction.UPDATE; + } - if (document) return CrudAction.UPDATE; + /** + * Gets the previous snapshot for the document + */ + private async getPreviousSnapshot(documentId: string, model: string) { + return await this.snapshotModel + .findOne({ documentId, model }) + .sort({ createdAt: -1 }) + .exec(); + } - return CrudAction.CREATE; + /** + * Calculates changes between two snapshots + */ + private 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), + })); } /** @@ -69,15 +95,19 @@ export class SnapshotsService { /** * Creates a snapshot - * @param payload Payload to create the snapshot - * @returns Created snapshot */ async create(payload: CreateSnapshotPayload) { const { model, data, documentId, workerId } = payload; - const action = await this.determinateAction(payload); + const previousSnapshot = await this.getPreviousSnapshot(documentId, model); + const action = await this.determinateAction(payload, previousSnapshot); const worker = await this.getWorker(workerId); + const changes = + action === CrudAction.UPDATE + ? this.calculateChanges(previousSnapshot?.data ?? null, data) + : []; + const snapshot = new this.snapshotModel({ model, data, @@ -85,6 +115,7 @@ export class SnapshotsService { action, worker, workerId: workerId ?? null, + changes, }); return await snapshot.save(); diff --git a/src/@base/snapshots/types/index.ts b/src/@base/snapshots/types/index.ts index 98a9c40..6de6935 100644 --- a/src/@base/snapshots/types/index.ts +++ b/src/@base/snapshots/types/index.ts @@ -12,3 +12,9 @@ export type CreateSnapshotPayload = { data: any; workerId?: string | null; }; + +export interface SnapshotChange { + path: string; + oldValue: any; + newValue: any; +} 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] }; +} From 07059a792036c8031acd393fed5c5c624797aba0 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Feb 2025 18:19:13 +0200 Subject: [PATCH 089/180] refactor: using quoue for snapshots saving --- src/@base/snapshots/snapshots.module.ts | 11 ++- src/@base/snapshots/snapshots.processor.ts | 75 +++++++++++++++++++++ src/@base/snapshots/snapshots.producer.ts | 38 +++++++++++ src/@base/snapshots/snapshots.service.ts | 36 ++-------- src/@base/snapshots/types/index.ts | 6 ++ src/orders/@queue/orders-queue.processor.ts | 7 +- 6 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 src/@base/snapshots/snapshots.processor.ts create mode 100644 src/@base/snapshots/snapshots.producer.ts diff --git a/src/@base/snapshots/snapshots.module.ts b/src/@base/snapshots/snapshots.module.ts index c4dd617..4f63ad0 100644 --- a/src/@base/snapshots/snapshots.module.ts +++ b/src/@base/snapshots/snapshots.module.ts @@ -1,6 +1,10 @@ +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"; @@ -11,9 +15,12 @@ import { SnapshotsService } from "./snapshots.service"; MongooseModule.forFeature([ { name: Snapshot.name, schema: SnapshotSchema }, ]), + BullModule.registerQueue({ + name: SNAPSHOTS_QUEUE, + }), ], controllers: [], - providers: [SnapshotsService], - exports: [SnapshotsService], + 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 index 89f1c41..27ae9ca 100644 --- a/src/@base/snapshots/snapshots.service.ts +++ b/src/@base/snapshots/snapshots.service.ts @@ -25,7 +25,7 @@ export class SnapshotsService { /** * Determines the action to be taken based on the payload and previous snapshot */ - private async determinateAction( + determinateAction( payload: CreateSnapshotPayload, previousSnapshot: SnapshotDocument | null, ) { @@ -41,7 +41,7 @@ export class SnapshotsService { /** * Gets the previous snapshot for the document */ - private async getPreviousSnapshot(documentId: string, model: string) { + async getPreviousSnapshot(documentId: string, model: string) { return await this.snapshotModel .findOne({ documentId, model }) .sort({ createdAt: -1 }) @@ -51,7 +51,7 @@ export class SnapshotsService { /** * Calculates changes between two snapshots */ - private calculateChanges(oldData: any, newData: any): SnapshotChange[] { + calculateChanges(oldData: any, newData: any): SnapshotChange[] { const { changedPaths } = deepCompare(oldData, newData); return changedPaths.map((path) => ({ @@ -70,7 +70,7 @@ export class SnapshotsService { * @param workerId ID of the worker * @returns Worker or null if worker is not found */ - private async getWorker(workerId?: string | null) { + async getWorker(workerId?: string | null) { if (!workerId) return null; const worker = await this.pg.query.workers.findFirst({ @@ -92,32 +92,4 @@ export class SnapshotsService { return worker ?? null; } - - /** - * Creates a snapshot - */ - async create(payload: CreateSnapshotPayload) { - const { model, data, documentId, workerId } = payload; - - const previousSnapshot = await this.getPreviousSnapshot(documentId, model); - const action = await this.determinateAction(payload, previousSnapshot); - const worker = await this.getWorker(workerId); - - const changes = - action === CrudAction.UPDATE - ? this.calculateChanges(previousSnapshot?.data ?? null, data) - : []; - - const snapshot = new this.snapshotModel({ - model, - data, - documentId, - action, - worker, - workerId: workerId ?? null, - changes, - }); - - return await snapshot.save(); - } } diff --git a/src/@base/snapshots/types/index.ts b/src/@base/snapshots/types/index.ts index 6de6935..8d53a8f 100644 --- a/src/@base/snapshots/types/index.ts +++ b/src/@base/snapshots/types/index.ts @@ -1,5 +1,11 @@ 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", diff --git a/src/orders/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts index cfa1412..5d5ea2b 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -3,7 +3,7 @@ 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 { SnapshotsService } from "src/@base/snapshots/snapshots.service"; +import { SnapshotsProducer } from "src/@base/snapshots/snapshots.producer"; import { PG_CONNECTION } from "src/constants"; import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; import { OrderCrudUpdateJobDto } from "src/orders/@queue/dto/crud-update.job"; @@ -18,7 +18,7 @@ export class OrdersQueueProcessor extends WorkerHost { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, private readonly ordersSocketNotifier: OrdersSocketNotifier, - private readonly snapshotsService: SnapshotsService, + private readonly snapshotsProducer: SnapshotsProducer, ) { super(); } @@ -59,11 +59,12 @@ export class OrdersQueueProcessor extends WorkerHost { */ private async recalculatePrices(data: RecalculatePricesJobDto) { const { orderId } = data; + orderId; } private async crudUpdate(data: OrderCrudUpdateJobDto) { // make snapshot - await this.snapshotsService.create({ + await this.snapshotsProducer.create({ model: "ORDERS", action: data.action, data: data.order, From a6fdd0d7dbe36f1243c5cfbc2fcc907473fd7430 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Feb 2025 18:31:28 +0200 Subject: [PATCH 090/180] feat: order snapshot entity for excluding not using data --- src/orders/@/entities/order-snapshot.entity.ts | 7 +++++++ src/orders/@queue/orders-queue.processor.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/orders/@/entities/order-snapshot.entity.ts 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/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts index 5d5ea2b..d82df6f 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -2,9 +2,11 @@ import { Processor, WorkerHost } from "@nestjs/bullmq"; import { Inject, Logger } from "@nestjs/common"; import { Schema } from "@postgress-db/drizzle.module"; import { Job } from "bullmq"; +import { plainToClass } from "class-transformer"; 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"; import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; import { OrderCrudUpdateJobDto } from "src/orders/@queue/dto/crud-update.job"; import { RecalculatePricesJobDto } from "src/orders/@queue/dto/recalculate-prices-job.dto"; @@ -67,7 +69,9 @@ export class OrdersQueueProcessor extends WorkerHost { await this.snapshotsProducer.create({ model: "ORDERS", action: data.action, - data: data.order, + data: plainToClass(OrderSnapshotEntity, data.order, { + excludeExtraneousValues: true, + }), documentId: data.orderId, workerId: data.calledByWorkerId, }); From 587f44223c925121797d50666789f42400eebe5b Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Feb 2025 18:33:29 +0200 Subject: [PATCH 091/180] feat: snapshotting for order creation --- src/orders/@/orders.controller.ts | 6 ++++-- src/orders/@/services/orders.service.ts | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index f09ed0a..a4170fc 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -33,8 +33,10 @@ export class OrdersController { description: "Order has been successfully created", type: OrderEntity, }) - async create(@Body() dto: CreateOrderDto) { - return this.ordersService.create(dto); + async create(@Body() dto: CreateOrderDto, @Worker() worker: RequestWorker) { + return this.ordersService.create(dto, { + workerId: worker.id, + }); } @Get(":id") diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index b53a468..199064d 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -115,7 +115,10 @@ export class OrdersService { } } - async create(dto: CreateOrderDto): Promise { + async create( + dto: CreateOrderDto, + opts?: { workerId?: string }, + ): Promise { await this.checkDto(dto); const { @@ -161,6 +164,7 @@ export class OrdersService { action: "CREATE", orderId: createdOrder.id, order, + calledByWorkerId: opts?.workerId, }); return order; From 21be323cc12700608ce93981aef30d4c90d6d882 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Feb 2025 18:46:16 +0200 Subject: [PATCH 092/180] feat: audit logs literally for everything --- src/addresses/addresses.controller.ts | 2 ++ src/dish-categories/dish-categories.controller.ts | 5 +++++ src/dishes/@/dishes.controller.ts | 5 +++++ src/dishes/images/dish-images.controller.ts | 4 ++++ src/dishes/pricelist/dish-pricelist.controller.ts | 3 +++ src/files/files.controller.ts | 3 +++ src/guests/guests.controller.ts | 5 +++++ src/orders/@/orders.controller.ts | 7 +++++++ src/orders/dispatcher/dispatcher-orders.controller.ts | 4 ++++ src/restaurants/@/controllers/restaurants.controller.ts | 5 +++++ src/restaurants/hours/restaurant-hours.controller.ts | 5 +++++ .../workshops/restaurant-workshops.controller.ts | 7 +++++++ src/workers/workers.controller.ts | 2 +- 13 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/addresses/addresses.controller.ts b/src/addresses/addresses.controller.ts index eb38e64..bc07b76 100644 --- a/src/addresses/addresses.controller.ts +++ b/src/addresses/addresses.controller.ts @@ -7,6 +7,7 @@ import { 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"; @@ -18,6 +19,7 @@ import { AddressesService } from "./services/addresses.service"; export class AddressesController { constructor(private readonly addressesService: AddressesService) {} + @EnableAuditLog({ onlyErrors: true }) @Get("suggestions") @ApiOperation({ summary: "Get address suggestions", diff --git a/src/dish-categories/dish-categories.controller.ts b/src/dish-categories/dish-categories.controller.ts index 93f3e47..4744887 100644 --- a/src/dish-categories/dish-categories.controller.ts +++ b/src/dish-categories/dish-categories.controller.ts @@ -18,6 +18,7 @@ import { 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"; @@ -31,6 +32,7 @@ import { DishCategoryEntity } from "./entities/dish-category.entity"; export class DishCategoriesController { constructor(private readonly dishCategoriesService: DishCategoriesService) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @ApiOperation({ summary: "Gets dish categories that are available in system", @@ -72,6 +74,7 @@ export class DishCategoriesController { }; } + @EnableAuditLog() @Post() @Serializable(DishCategoryEntity) @ApiOperation({ summary: "Creates a new dish category" }) @@ -90,6 +93,7 @@ export class DishCategoriesController { return category; } + @EnableAuditLog({ onlyErrors: true }) @Get(":id") @Serializable(DishCategoryEntity) @ApiOperation({ summary: "Gets a dish category by id" }) @@ -117,6 +121,7 @@ export class DishCategoriesController { return category; } + @EnableAuditLog() @Put(":id") @Serializable(DishCategoryEntity) @ApiOperation({ summary: "Updates a dish category by id" }) diff --git a/src/dishes/@/dishes.controller.ts b/src/dishes/@/dishes.controller.ts index 190a2b4..8345295 100644 --- a/src/dishes/@/dishes.controller.ts +++ b/src/dishes/@/dishes.controller.ts @@ -29,6 +29,7 @@ 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"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; @Controller("dishes") @ApiForbiddenResponse({ description: "Forbidden" }) @@ -36,6 +37,7 @@ import { DishesPaginatedDto } from "./entities/dishes-paginated.entity"; export class DishesController { constructor(private readonly dishesService: DishesService) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @ApiOperation({ summary: "Gets dishes that are available in system", @@ -90,6 +92,7 @@ export class DishesController { }; } + @EnableAuditLog() @Post() @Serializable(DishEntity) @ApiOperation({ summary: "Creates a new dish" }) @@ -104,6 +107,7 @@ export class DishesController { return dish; } + @EnableAuditLog({ onlyErrors: true }) @Get(":id") @Serializable(DishEntity) @ApiOperation({ summary: "Gets a dish by id" }) @@ -133,6 +137,7 @@ export class DishesController { return dish; } + @EnableAuditLog() @Put(":id") @Serializable(DishEntity) @ApiOperation({ summary: "Updates a dish by id" }) diff --git a/src/dishes/images/dish-images.controller.ts b/src/dishes/images/dish-images.controller.ts index e2818f4..d402eb7 100644 --- a/src/dishes/images/dish-images.controller.ts +++ b/src/dishes/images/dish-images.controller.ts @@ -11,6 +11,7 @@ import { } 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"; @@ -26,6 +27,7 @@ import { UploadDishImageDto } from "./dto/upload-dish-image.dto"; export class DishImagesController { constructor(private readonly dishImagesService: DishImagesService) {} + @EnableAuditLog() @Post() @FormDataRequest() @Serializable(DishImageEntity) @@ -45,6 +47,7 @@ export class DishImagesController { }); } + @EnableAuditLog() @Put(":imageId") @Serializable(DishImageEntity) @ApiOperation({ summary: "Update dish image details" }) @@ -63,6 +66,7 @@ export class DishImagesController { }); } + @EnableAuditLog() @Delete(":imageId") @ApiOperation({ summary: "Delete an image from dish" }) @ApiOkResponse({ diff --git a/src/dishes/pricelist/dish-pricelist.controller.ts b/src/dishes/pricelist/dish-pricelist.controller.ts index c2a28eb..8c7d819 100644 --- a/src/dishes/pricelist/dish-pricelist.controller.ts +++ b/src/dishes/pricelist/dish-pricelist.controller.ts @@ -6,6 +6,7 @@ import { ApiOperation, ApiResponse } from "@nestjs/swagger"; import { DishPricelistService } from "./dish-pricelist.service"; import { UpdateDishPricelistDto } from "./dto/update-dish-pricelist.dto"; import DishPricelistItemEntity from "./entities/dish-pricelist-item.entity"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; @Controller("dishes/:id/pricelist", { tags: ["dishes"], @@ -13,6 +14,7 @@ import DishPricelistItemEntity from "./entities/dish-pricelist-item.entity"; export class DishPricelistController { constructor(private readonly dishPricelistService: DishPricelistService) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @Serializable(DishPricelistItemEntity) @ApiOperation({ summary: "Get dish pricelist" }) @@ -25,6 +27,7 @@ export class DishPricelistController { return this.dishPricelistService.getPricelist(dishId); } + @EnableAuditLog() @Put(":restaurantId") @Serializable(DishPricelistItemEntity) @ApiOperation({ summary: "Update dish pricelist for restaurant" }) diff --git a/src/files/files.controller.ts b/src/files/files.controller.ts index cfd55c0..9b48d12 100644 --- a/src/files/files.controller.ts +++ b/src/files/files.controller.ts @@ -11,6 +11,7 @@ import { } 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"; @@ -21,6 +22,7 @@ import { FilesService } from "src/files/files.service"; export class FilesController { constructor(private readonly filesService: FilesService) {} + @EnableAuditLog() @Post("upload") @FormDataRequest() @Serializable(FileEntity) @@ -36,6 +38,7 @@ export class FilesController { }); } + @EnableAuditLog() @Delete(":id") @ApiOperation({ summary: "Deletes a file" }) @ApiOkResponse({ diff --git a/src/guests/guests.controller.ts b/src/guests/guests.controller.ts index 6ed796c..847c271 100644 --- a/src/guests/guests.controller.ts +++ b/src/guests/guests.controller.ts @@ -18,6 +18,7 @@ import { 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"; @@ -31,6 +32,7 @@ import { GuestsService } from "./guests.service"; export class GuestsController { constructor(private readonly guestsService: GuestsService) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @ApiOperation({ summary: "Gets guests that available in system", @@ -73,6 +75,7 @@ export class GuestsController { }; } + @EnableAuditLog() @Post() @Serializable(GuestEntity) @ApiOperation({ summary: "Creates a new guest" }) @@ -87,6 +90,7 @@ export class GuestsController { return guest; } + @EnableAuditLog({ onlyErrors: true }) @Get(":id") @Serializable(GuestEntity) @ApiOperation({ summary: "Gets a guest by id" }) @@ -116,6 +120,7 @@ export class GuestsController { return guest; } + @EnableAuditLog() @Put(":id") @Serializable(GuestEntity) @ApiOperation({ summary: "Updates a guest by id" }) diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index a4170fc..6787af8 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -11,6 +11,7 @@ import { 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 { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; @@ -26,6 +27,7 @@ export class OrdersController { private readonly orderDishesService: OrderDishesService, ) {} + @EnableAuditLog() @Post() @Serializable(OrderEntity) @ApiOperation({ summary: "Creates a new order" }) @@ -39,6 +41,7 @@ export class OrdersController { }); } + @EnableAuditLog({ onlyErrors: true }) @Get(":id") @Serializable(OrderEntity) @ApiOperation({ summary: "Gets order by id" }) @@ -62,6 +65,7 @@ export class OrdersController { return await this.ordersService.findById(id); } + @EnableAuditLog() @Patch(":id") @Serializable(OrderEntity) @ApiOperation({ summary: "Updates an order" }) @@ -83,6 +87,7 @@ export class OrdersController { }); } + @EnableAuditLog() @Post(":id/dishes") @Serializable(OrderEntity) @ApiOperation({ summary: "Adds a dish to the order" }) @@ -105,6 +110,7 @@ export class OrdersController { return this.ordersService.findById(orderId); } + @EnableAuditLog() @Patch(":id/dishes/:orderDishId") @Serializable(OrderEntity) @ApiOperation({ summary: "Updates a dish in the order" }) @@ -126,6 +132,7 @@ export class OrdersController { return this.ordersService.findById(orderId); } + @EnableAuditLog() @Delete(":id/dishes/:orderDishId") @ApiOperation({ summary: "Removes a dish from the order" }) @ApiOkResponse({ diff --git a/src/orders/dispatcher/dispatcher-orders.controller.ts b/src/orders/dispatcher/dispatcher-orders.controller.ts index 0d24a6a..fa0d0d0 100644 --- a/src/orders/dispatcher/dispatcher-orders.controller.ts +++ b/src/orders/dispatcher/dispatcher-orders.controller.ts @@ -4,6 +4,7 @@ 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"; @@ -15,6 +16,7 @@ export class DispatcherOrdersController { private readonly dispatcherOrdersService: DispatcherOrdersService, ) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @Serializable(DispatcherOrdersPaginatedEntity) @ApiOperation({ @@ -47,6 +49,7 @@ export class DispatcherOrdersController { }; } + @EnableAuditLog({ onlyErrors: true }) @Get("attention-required") @Serializable(DispatcherOrdersPaginatedEntity) @ApiOperation({ @@ -79,6 +82,7 @@ export class DispatcherOrdersController { }; } + @EnableAuditLog({ onlyErrors: true }) @Get("delayed") @Serializable(DispatcherOrdersPaginatedEntity) @ApiOperation({ diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts index 13e9a79..e4979a4 100644 --- a/src/restaurants/@/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -15,6 +15,7 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; @@ -28,6 +29,7 @@ import { RestaurantsService } from "../services/restaurants.service"; export class RestaurantsController { constructor(private readonly restaurantsService: RestaurantsService) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @ApiOperation({ summary: "Gets restaurants that created in system", @@ -50,6 +52,7 @@ export class RestaurantsController { }; } + @EnableAuditLog() @Post() @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @Serializable(RestaurantEntity) @@ -83,6 +86,7 @@ export class RestaurantsController { return await this.restaurantsService.findById(id); } + @EnableAuditLog() @Put(":id") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @Serializable(RestaurantEntity) @@ -106,6 +110,7 @@ export class RestaurantsController { return await this.restaurantsService.update(id, dto); } + @EnableAuditLog() @Delete(":id") @Roles("SYSTEM_ADMIN") @ApiOperation({ diff --git a/src/restaurants/hours/restaurant-hours.controller.ts b/src/restaurants/hours/restaurant-hours.controller.ts index 441b3ca..e315c41 100644 --- a/src/restaurants/hours/restaurant-hours.controller.ts +++ b/src/restaurants/hours/restaurant-hours.controller.ts @@ -9,6 +9,7 @@ import { ApiOperation, OmitType, } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; import { CreateRestaurantHoursDto, @@ -30,6 +31,7 @@ export class RestaurantHoursController { private readonly restaurantHoursService: RestaurantHoursService, ) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @Serializable(RestaurantHoursEntity) @ApiOperation({ summary: "Gets restaurant hours" }) @@ -41,6 +43,7 @@ export class RestaurantHoursController { return await this.restaurantHoursService.findMany(id); } + @EnableAuditLog() @Post() @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Creates restaurant hours" }) @@ -60,6 +63,7 @@ export class RestaurantHoursController { }); } + @EnableAuditLog() @Put(":hoursId") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @Serializable(RestaurantHoursEntity) @@ -77,6 +81,7 @@ export class RestaurantHoursController { return await this.restaurantHoursService.update(id, dto); } + @EnableAuditLog() @Delete(":hoursId") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Deletes restaurant hours" }) diff --git a/src/restaurants/workshops/restaurant-workshops.controller.ts b/src/restaurants/workshops/restaurant-workshops.controller.ts index eb15cba..8134418 100644 --- a/src/restaurants/workshops/restaurant-workshops.controller.ts +++ b/src/restaurants/workshops/restaurant-workshops.controller.ts @@ -9,6 +9,7 @@ import { ApiOperation, OmitType, } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; import { UpdateRestaurantWorkshopWorkersDto } from "./dto/put-restaurant-workshop-workers.dto"; import { WorkshopWorkerEntity } from "./entity/restaurant-workshop-worker.entity"; @@ -32,6 +33,7 @@ export class RestaurantWorkshopsController { private readonly restaurantWorkshopsService: RestaurantWorkshopsService, ) {} + @EnableAuditLog({ onlyErrors: true }) @Get() @Serializable(RestaurantWorkshopDto) @ApiOperation({ summary: "Gets restaurant workshops" }) @@ -43,6 +45,7 @@ export class RestaurantWorkshopsController { return await this.restaurantWorkshopsService.findMany(id); } + @EnableAuditLog() @Post() @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Creates restaurant workshop" }) @@ -62,6 +65,7 @@ export class RestaurantWorkshopsController { }); } + @EnableAuditLog() @Put(":workshopId") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @Serializable(RestaurantWorkshopDto) @@ -79,6 +83,7 @@ export class RestaurantWorkshopsController { return await this.restaurantWorkshopsService.update(id, dto); } + @EnableAuditLog({ onlyErrors: true }) @Get(":workshopId/workers") @Serializable(WorkshopWorkerEntity) @ApiOperation({ summary: "Gets workshop workers" }) @@ -90,6 +95,7 @@ export class RestaurantWorkshopsController { return await this.restaurantWorkshopsService.getWorkers(id); } + @EnableAuditLog() @Put(":workshopId/workers") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Updates workshop workers" }) @@ -107,6 +113,7 @@ export class RestaurantWorkshopsController { return; } + @EnableAuditLog() @Delete(":workshopId") @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Deletes restaurant workshop" }) diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index 928d3e2..96dc025 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -81,7 +81,7 @@ export class WorkersController { } // TODO: add validation of ADMIN restaurant id - @EnableAuditLog({ onlyErrors: true }) + @EnableAuditLog() @Post() @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN", "ADMIN") @Serializable(WorkerEntity) From 83767921fcd1470a034f234f040c1873e8e9d254 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Feb 2025 19:34:24 +0200 Subject: [PATCH 093/180] feat: bullmq for audit logs --- .../audit-logs/audit-logs.interceptor.ts | 8 ++-- src/@base/audit-logs/audit-logs.module.ts | 16 +++++++- src/@base/audit-logs/audit-logs.processor.ts | 40 +++++++++++++++++++ src/@base/audit-logs/audit-logs.producer.ts | 36 +++++++++++++++++ src/@base/audit-logs/types/index.ts | 5 +++ 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 src/@base/audit-logs/audit-logs.processor.ts create mode 100644 src/@base/audit-logs/audit-logs.producer.ts create mode 100644 src/@base/audit-logs/types/index.ts diff --git a/src/@base/audit-logs/audit-logs.interceptor.ts b/src/@base/audit-logs/audit-logs.interceptor.ts index f1aa170..24c0626 100644 --- a/src/@base/audit-logs/audit-logs.interceptor.ts +++ b/src/@base/audit-logs/audit-logs.interceptor.ts @@ -11,7 +11,7 @@ import { I18nContext } from "nestjs-i18n"; import { Observable, tap } from "rxjs"; import { v4 as uuidv4 } from "uuid"; -import { AuditLogsService } from "./audit-logs.service"; +import { AuditLogsProducer } from "./audit-logs.producer"; import { AUDIT_LOG_OPTIONS, AuditLogOptions, @@ -22,7 +22,7 @@ import { export class AuditLogsInterceptor implements NestInterceptor { constructor( private readonly reflector: Reflector, - private readonly auditLogsService: AuditLogsService, + private readonly auditLogsProducer: AuditLogsProducer, ) {} public readonly sensitiveFields = ["password", "token", "refreshToken"]; @@ -97,7 +97,7 @@ export class AuditLogsInterceptor implements NestInterceptor { const duration = Date.now() - startTime; - this.auditLogsService.create({ + this.auditLogsProducer.create({ method: request.method, url: request.url, params, @@ -121,7 +121,7 @@ export class AuditLogsInterceptor implements NestInterceptor { const duration = Date.now() - startTime; const i18n = I18nContext.current(); - this.auditLogsService.create({ + this.auditLogsProducer.create({ method: request.method, url: request.url, params, diff --git a/src/@base/audit-logs/audit-logs.module.ts b/src/@base/audit-logs/audit-logs.module.ts index 7c09d81..9233c57 100644 --- a/src/@base/audit-logs/audit-logs.module.ts +++ b/src/@base/audit-logs/audit-logs.module.ts @@ -1,18 +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], - exports: [AuditLogsService], + 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/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", +} From 53ad137a2eb53fd1879dd8e85cbe539fac976f0d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 13 Feb 2025 09:43:13 +0200 Subject: [PATCH 094/180] feat: order dishes snapshots --- src/@base/snapshots/types/index.ts | 1 + .../@/entities/order-dish-snapshot.entity.ts | 3 + src/orders/@/orders.controller.ts | 15 ++++- src/orders/@/services/order-dishes.service.ts | 61 +++++++++++++++---- src/orders/@queue/dto/crud-update.job.ts | 8 +++ src/orders/@queue/index.ts | 1 + src/orders/@queue/orders-queue.processor.ts | 24 +++++++- src/orders/@queue/orders-queue.producer.ts | 12 +++- 8 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 src/orders/@/entities/order-dish-snapshot.entity.ts diff --git a/src/@base/snapshots/types/index.ts b/src/@base/snapshots/types/index.ts index 8d53a8f..fd642ba 100644 --- a/src/@base/snapshots/types/index.ts +++ b/src/@base/snapshots/types/index.ts @@ -9,6 +9,7 @@ export enum SnapshotQueueJobName { export enum SnapshotModel { RESTAURANTS = "RESTAURANTS", ORDERS = "ORDERS", + ORDER_DISHES = "ORDER_DISHES", } export type CreateSnapshotPayload = { 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/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 6787af8..530815a 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -104,8 +104,11 @@ export class OrdersController { async addDish( @Param("id") orderId: string, @Body() payload: AddOrderDishDto, + @Worker() worker: RequestWorker, ) { - await this.orderDishesService.addToOrder(orderId, payload); + await this.orderDishesService.addToOrder(orderId, payload, { + workerId: worker.id, + }); return this.ordersService.findById(orderId); } @@ -126,8 +129,11 @@ export class OrdersController { @Param("id") orderId: string, @Param("orderDishId") orderDishId: string, @Body() payload: UpdateOrderDishDto, + @Worker() worker: RequestWorker, ) { - await this.orderDishesService.update(orderDishId, payload); + await this.orderDishesService.update(orderDishId, payload, { + workerId: worker.id, + }); return this.ordersService.findById(orderId); } @@ -146,8 +152,11 @@ export class OrdersController { async removeDish( @Param("id") orderId: string, @Param("orderDishId") orderDishId: string, + @Worker() worker: RequestWorker, ) { - await this.orderDishesService.remove(orderDishId); + await this.orderDishesService.remove(orderDishId, { + workerId: worker.id, + }); return this.ordersService.findById(orderId); } diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 4dc7eec..31f4c5e 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -130,7 +130,11 @@ export class OrderDishesService { return true; } - public async addToOrder(orderId: string, payload: AddOrderDishDto) { + public async addToOrder( + orderId: string, + payload: AddOrderDishDto, + opts?: { workerId?: string }, + ) { const { dishId, quantity } = payload; const order = await this.getOrder(orderId); @@ -156,9 +160,7 @@ export class OrderDishesService { price: String(price), finalPrice: String(price), }) - .returning({ - id: orderDishes.id, - }); + .returning(); await this.orderPricesService.calculateOrderTotals(orderId, { tx, @@ -167,10 +169,21 @@ export class OrderDishesService { return orderDish; }); + await this.ordersProducer.dishCrudUpdate({ + action: "CREATE", + orderDishId: orderDish.id, + orderDish, + calledByWorkerId: opts?.workerId, + }); + return orderDish; } - public async update(orderDishId: string, payload: UpdateOrderDishDto) { + public async update( + orderDishId: string, + payload: UpdateOrderDishDto, + opts?: { workerId?: string }, + ) { const { quantity } = payload; if (quantity <= 0) { @@ -191,36 +204,60 @@ export class OrderDishesService { throw new BadRequestException("errors.order-dishes.is-removed"); } - await this.pg.transaction(async (tx) => { - await tx + const updatedOrderDish = await this.pg.transaction(async (tx) => { + const [updatedOrderDish] = await tx .update(orderDishes) .set({ quantity, }) - .where(eq(orderDishes.id, orderDishId)); + .where(eq(orderDishes.id, orderDishId)) + .returning(); await this.orderPricesService.calculateOrderTotals(orderDish.orderId, { tx, }); + + return updatedOrderDish; + }); + + await this.ordersProducer.dishCrudUpdate({ + action: "UPDATE", + orderDishId: orderDish.id, + orderDish: updatedOrderDish, + calledByWorkerId: opts?.workerId, }); + + return updatedOrderDish; } - public async remove(orderDishId: string) { + 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"); } - await this.pg.transaction(async (tx) => { - await tx + const removedOrderDish = await this.pg.transaction(async (tx) => { + const [removedOrderDish] = await tx .update(orderDishes) .set({ isRemoved: true, removedAt: new Date() }) - .where(eq(orderDishes.id, orderDishId)); + .where(eq(orderDishes.id, orderDishId)) + .returning(); await this.orderPricesService.calculateOrderTotals(orderDish.orderId, { tx, }); + + return removedOrderDish; }); + + await this.ordersProducer.dishCrudUpdate({ + action: "DELETE", + orderDishId: orderDish.id, + orderDish: removedOrderDish, + calledByWorkerId: opts?.workerId, + }); + + return removedOrderDish; } } diff --git a/src/orders/@queue/dto/crud-update.job.ts b/src/orders/@queue/dto/crud-update.job.ts index 749a1fb..52941f7 100644 --- a/src/orders/@queue/dto/crud-update.job.ts +++ b/src/orders/@queue/dto/crud-update.job.ts @@ -1,4 +1,5 @@ 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 { @@ -7,3 +8,10 @@ export class OrderCrudUpdateJobDto { action: `${CrudAction}`; calledByWorkerId?: string; } + +export class OrderDishCrudUpdateJobDto { + orderDishId: string; + orderDish: OrderDishEntity; + action: `${CrudAction}`; + calledByWorkerId?: string; +} diff --git a/src/orders/@queue/index.ts b/src/orders/@queue/index.ts index f81a29d..58adab4 100644 --- a/src/orders/@queue/index.ts +++ b/src/orders/@queue/index.ts @@ -2,5 +2,6 @@ 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.processor.ts b/src/orders/@queue/orders-queue.processor.ts index d82df6f..6ecf19d 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -6,9 +6,13 @@ import { plainToClass } from "class-transformer"; 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 { OrderSnapshotEntity } from "src/orders/@/entities/order-snapshot.entity"; import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; -import { OrderCrudUpdateJobDto } from "src/orders/@queue/dto/crud-update.job"; +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"; @@ -44,6 +48,11 @@ export class OrdersQueueProcessor extends WorkerHost { break; } + case OrderQueueJobName.DISH_CRUD_UPDATE: { + await this.dishCrudUpdate(data as OrderDishCrudUpdateJobDto); + break; + } + default: { throw new Error(`Unknown job name`); } @@ -79,4 +88,17 @@ export class OrdersQueueProcessor extends WorkerHost { // notify users await this.ordersSocketNotifier.handle(data.order); } + + private async dishCrudUpdate(data: OrderDishCrudUpdateJobDto) { + // make snapshot + await this.snapshotsProducer.create({ + model: "ORDER_DISHES", + action: data.action, + data: plainToClass(OrderDishSnapshotEntity, data.orderDish, { + excludeExtraneousValues: true, + }), + documentId: data.orderDishId, + workerId: data.calledByWorkerId, + }); + } } diff --git a/src/orders/@queue/orders-queue.producer.ts b/src/orders/@queue/orders-queue.producer.ts index 694d6b4..6d0f33c 100644 --- a/src/orders/@queue/orders-queue.producer.ts +++ b/src/orders/@queue/orders-queue.producer.ts @@ -2,7 +2,10 @@ 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 } from "src/orders/@queue/dto/crud-update.job"; +import { + OrderCrudUpdateJobDto, + OrderDishCrudUpdateJobDto, +} from "src/orders/@queue/dto/crud-update.job"; import { RecalculatePricesJobDto } from "src/orders/@queue/dto/recalculate-prices-job.dto"; @Injectable() @@ -30,6 +33,13 @@ export class OrdersQueueProducer { 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 From 75cbed1d3fcf0e0c1cdc86eb10ae233f77aeb249 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 13 Feb 2025 10:20:07 +0200 Subject: [PATCH 095/180] feat: multiple orders subscription --- src/@socket/socket.gateway.ts | 21 +++++- src/@socket/socket.types.ts | 71 ++++++++++++++++--- .../orders-socket-notifier.service.ts | 7 +- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index afe43f0..f2dfa17 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -13,6 +13,7 @@ import Redis from "ioredis"; import { Socket } from "socket.io"; import { RedisChannels } from "src/@base/redis/channels"; import { + ClientSubscriptionType, GatewayClient, GatewayClients, GatewayClientSubscription, @@ -405,7 +406,25 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { let subscriptions = this.localSubscriptionsMap.get(clientId) ?? []; switch (type) { - case "ORDER": { + 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, diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index a14092a..98f292a 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -7,27 +7,82 @@ export enum GatewayIncomingMessage { 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 type GatewayClientSubscription = { - id: string; - clientId: string; - type: `${ClientSubscriptionType.ORDER}`; - data: ClientOrderSubscription; -}; +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 = GatewayClientSubscription & { +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; diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index 857839b..5c5f339 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { plainToClass } from "class-transformer"; import { SocketService } from "src/@socket/socket.service"; import { + ClientSubscriptionType, GatewayClient, SocketEventType, SocketOrderUpdateEvent, @@ -35,8 +36,10 @@ export class OrdersSocketNotifier { subscriptions.forEach((subscription) => { if ( - subscription.type === "ORDER" && - subscription.data.orderId === order.id + (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; From 150b6fa700bab5c48d2091d6827d0e3964f920f2 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 14 Feb 2025 18:56:29 +0200 Subject: [PATCH 096/180] feat: docs for audit log --- .../audit-logs/schemas/audit-log.schema.ts | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/@base/audit-logs/schemas/audit-log.schema.ts b/src/@base/audit-logs/schemas/audit-log.schema.ts index 1eb8dc8..108a7d2 100644 --- a/src/@base/audit-logs/schemas/audit-log.schema.ts +++ b/src/@base/audit-logs/schemas/audit-log.schema.ts @@ -3,64 +3,88 @@ 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; // Request duration in milliseconds + duration: number; + /** Unique identifier for correlating related requests */ @Prop() - requestId: string; // Unique identifier for the request + requestId: string; + /** Request headers */ @Prop({ type: Object }) headers: Record; + /** Origin or referer of the request */ @Prop() - origin: string; // Request origin/referer + origin: string; + /** Session identifier for tracking user sessions */ @Prop({ required: false }) sessionId?: string; + /** Worker identifier for distributed systems */ @Prop({ required: false }) workerId?: string; - // Automatically managed by timestamps option + /** Timestamp when the log was created */ createdAt: Date; + + /** Timestamp when the log was last updated */ updatedAt: Date; } From 62a1b014504bf7060b1dfbb8c6036cc6fa8c7e15 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 14:41:19 +0200 Subject: [PATCH 097/180] feat: notify users about order dishes changes by orderId --- src/orders/@queue/orders-queue.module.ts | 4 +++- src/orders/@queue/orders-queue.processor.ts | 3 +++ .../services/orders-socket-notifier.service.ts | 17 ++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/orders/@queue/orders-queue.module.ts b/src/orders/@queue/orders-queue.module.ts index 7e007e2..7184f7b 100644 --- a/src/orders/@queue/orders-queue.module.ts +++ b/src/orders/@queue/orders-queue.module.ts @@ -1,5 +1,5 @@ import { BullModule } from "@nestjs/bullmq"; -import { Module } from "@nestjs/common"; +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"; @@ -7,12 +7,14 @@ 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, }), diff --git a/src/orders/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts index 6ecf19d..27d946d 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -100,5 +100,8 @@ export class OrdersQueueProcessor extends WorkerHost { documentId: data.orderDishId, workerId: data.calledByWorkerId, }); + + // notify users + await this.ordersSocketNotifier.handleById(data.orderDish.orderId); } } diff --git a/src/orders/@queue/services/orders-socket-notifier.service.ts b/src/orders/@queue/services/orders-socket-notifier.service.ts index 5c5f339..3bf2dde 100644 --- a/src/orders/@queue/services/orders-socket-notifier.service.ts +++ b/src/orders/@queue/services/orders-socket-notifier.service.ts @@ -8,12 +8,27 @@ import { 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) {} + 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 From f89e6249e87ebdca8ea4f85cb8c978e3be7cd9b6 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 15:33:21 +0200 Subject: [PATCH 098/180] feat: completedAt field for order --- src/@base/drizzle/schema/orders.ts | 1 + src/orders/@/entities/order.entity.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts index 716ceb3..d91d5e0 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -108,6 +108,7 @@ export const orders = pgTable( // Default timestamps createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), + completedAt: timestamp("completedAt"), removedAt: timestamp("removedAt"), delayedTo: timestamp("delayedTo"), }, diff --git a/src/orders/@/entities/order.entity.ts b/src/orders/@/entities/order.entity.ts index 3188eb8..0a3d64f 100644 --- a/src/orders/@/entities/order.entity.ts +++ b/src/orders/@/entities/order.entity.ts @@ -238,6 +238,15 @@ export class OrderEntity implements IOrder { }) updatedAt: Date; + @IsDate() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Date when order was completed", + example: null, + }) + completedAt: Date | null; + @IsDate() @IsOptional() @Expose() From 70a5d9e701417b5a199ddec55a2db892b603d66c Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 16:12:35 +0200 Subject: [PATCH 099/180] feat: available actions endpoint --- .../order-available-actions.entity.ts | 29 +++++++++++ src/orders/@/orders.controller.ts | 12 +++++ src/orders/@/services/orders.service.ts | 50 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 src/orders/@/entities/order-available-actions.entity.ts 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/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 530815a..62a04d0 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -16,6 +16,7 @@ import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; import { UpdateOrderDto } from "src/orders/@/dtos/update-order.dto"; +import { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; 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"; @@ -65,6 +66,17 @@ export class OrdersController { return await this.ordersService.findById(id); } + @Get(":id/available-actions") + @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.ordersService.getAvailableActions(id); + } + @EnableAuditLog() @Patch(":id") @Serializable(OrderEntity) diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 199064d..8184633 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -11,6 +11,7 @@ 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 { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; import { OrderEntity } from "src/orders/@/entities/order.entity"; import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @@ -282,4 +283,53 @@ export class OrdersService { return this.attachRestaurantsName([order])[0]; } + + 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; + } } From e24b0cce29914ed8a08140ebc5e927c195e0e5ce Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 16:44:53 +0200 Subject: [PATCH 100/180] refactor: controller for order actions --- src/orders/@/order-actions.controller.ts | 24 ++++++++++++++++++++++++ src/orders/@/orders.controller.ts | 12 ------------ src/orders/orders.module.ts | 7 ++++++- 3 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 src/orders/@/order-actions.controller.ts diff --git a/src/orders/@/order-actions.controller.ts b/src/orders/@/order-actions.controller.ts new file mode 100644 index 0000000..da4cb87 --- /dev/null +++ b/src/orders/@/order-actions.controller.ts @@ -0,0 +1,24 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Get, Param } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; +import { OrdersService } from "src/orders/@/services/orders.service"; + +@Controller("orders/:id/actions", { + tags: ["orders"], +}) +export class OrderActionsController { + constructor(private readonly ordersService: OrdersService) {} + + @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.ordersService.getAvailableActions(id); + } +} diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 62a04d0..530815a 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -16,7 +16,6 @@ import { AddOrderDishDto } from "src/orders/@/dtos/add-order-dish.dto"; import { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.dto"; import { UpdateOrderDto } from "src/orders/@/dtos/update-order.dto"; -import { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; 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"; @@ -66,17 +65,6 @@ export class OrdersController { return await this.ordersService.findById(id); } - @Get(":id/available-actions") - @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.ordersService.getAvailableActions(id); - } - @EnableAuditLog() @Patch(":id") @Serializable(OrderEntity) diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index 57b5cc1..301d7f2 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.module"; import { GuestsModule } from "src/guests/guests.module"; +import { OrderActionsController } from "src/orders/@/order-actions.controller"; import { OrdersController } from "src/orders/@/orders.controller"; import { OrderDishesService } from "src/orders/@/services/order-dishes.service"; import { OrderPricesService } from "src/orders/@/services/order-prices.service"; @@ -17,7 +18,11 @@ import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders OrderDishesService, OrderPricesService, ], - controllers: [OrdersController, DispatcherOrdersController], + controllers: [ + OrdersController, + DispatcherOrdersController, + OrderActionsController, + ], exports: [OrdersService, OrderDishesService], }) export class OrdersModule {} From e4fd592892ed761464e22aa31fa5994c7f13ba38 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 17:59:37 +0200 Subject: [PATCH 101/180] feat: send to kitchen api endpoint --- src/i18n/messages/en/errors.json | 3 + src/orders/@/order-actions.controller.ts | 26 +++- .../@/services/order-actions.service.ts | 120 ++++++++++++++++++ src/orders/@/services/orders.service.ts | 50 -------- src/orders/orders.module.ts | 2 + 5 files changed, 148 insertions(+), 53 deletions(-) create mode 100644 src/orders/@/services/order-actions.service.ts diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index e61583c..2c8783e 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -81,5 +81,8 @@ "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" + }, + "order-actions": { + "no-dishes-is-pending": "No dishes are pending" } } diff --git a/src/orders/@/order-actions.controller.ts b/src/orders/@/order-actions.controller.ts index da4cb87..128e1de 100644 --- a/src/orders/@/order-actions.controller.ts +++ b/src/orders/@/order-actions.controller.ts @@ -1,15 +1,21 @@ import { Controller } from "@core/decorators/controller.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; -import { Get, Param } from "@nestjs/common"; +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) {} + constructor( + private readonly ordersService: OrdersService, + private readonly orderActionsService: OrderActionsService, + ) {} @Get("available") @Serializable(OrderAvailableActionsEntity) @@ -19,6 +25,20 @@ export class OrderActionsController { type: OrderAvailableActionsEntity, }) async getAvailableActions(@Param("id") id: string) { - return this.ordersService.getAvailableActions(id); + 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/@/services/order-actions.service.ts b/src/orders/@/services/order-actions.service.ts new file mode 100644 index 0000000..b9f6116 --- /dev/null +++ b/src/orders/@/services/order-actions.service.ts @@ -0,0 +1,120 @@ +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 { orderDishes } from "@postgress-db/schema/order-dishes"; +import { orders } from "@postgress-db/schema/orders"; +import { eq, inArray } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; + +@Injectable() +export class OrderActionsService { + constructor( + @Inject(PG_CONNECTION) + private readonly pg: NodePgDatabase, + ) {} + + 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 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 + await tx + .update(orderDishes) + .set({ + status: "cooking", + isAdditional, + }) + .where( + inArray( + orderDishes.id, + pendingDishes.map((d) => d.id), + ), + ); + + // Set cooking status for order + await tx + .update(orders) + .set({ + status: "cooking", + }) + .where(eq(orders.id, orderId)); + }); + } +} diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 8184633..199064d 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -11,7 +11,6 @@ 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 { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; import { OrderEntity } from "src/orders/@/entities/order.entity"; import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @@ -283,53 +282,4 @@ export class OrdersService { return this.attachRestaurantsName([order])[0]; } - - 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; - } } diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index 301d7f2..af3a0b4 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -3,6 +3,7 @@ import { DrizzleModule } from "@postgress-db/drizzle.module"; import { GuestsModule } from "src/guests/guests.module"; import { OrderActionsController } from "src/orders/@/order-actions.controller"; import { OrdersController } from "src/orders/@/orders.controller"; +import { OrderActionsService } from "src/orders/@/services/order-actions.service"; import { OrderDishesService } from "src/orders/@/services/order-dishes.service"; import { OrderPricesService } from "src/orders/@/services/order-prices.service"; import { OrdersService } from "src/orders/@/services/orders.service"; @@ -17,6 +18,7 @@ import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders DispatcherOrdersService, OrderDishesService, OrderPricesService, + OrderActionsService, ], controllers: [ OrdersController, From 5921d882e01a9c17d2240edd8c27eeea367354fd Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 18:33:46 +0200 Subject: [PATCH 102/180] refactor: individual controller for order dishes --- src/orders/@/order-dishes.controller.ts | 101 ++++++++++++++++++ src/orders/@/orders.controller.ts | 78 +------------- .../@/services/order-actions.service.ts | 2 + src/orders/orders.module.ts | 2 + 4 files changed, 106 insertions(+), 77 deletions(-) create mode 100644 src/orders/@/order-dishes.controller.ts diff --git a/src/orders/@/order-dishes.controller.ts b/src/orders/@/order-dishes.controller.ts new file mode 100644 index 0000000..ef049c1 --- /dev/null +++ b/src/orders/@/order-dishes.controller.ts @@ -0,0 +1,101 @@ +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 } 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 { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.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/:id/dishes", { + tags: ["orders"], +}) +export class OrderDishesController { + constructor( + private readonly ordersService: OrdersService, + private readonly orderDishesService: OrderDishesService, + ) {} + + @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); + } +} diff --git a/src/orders/@/orders.controller.ts b/src/orders/@/orders.controller.ts index 530815a..004699a 100644 --- a/src/orders/@/orders.controller.ts +++ b/src/orders/@/orders.controller.ts @@ -3,7 +3,7 @@ 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, Delete, Get, Param, Patch, Post } from "@nestjs/common"; +import { Body, Get, Param, Patch, Post } from "@nestjs/common"; import { ApiBadRequestResponse, ApiCreatedResponse, @@ -12,9 +12,7 @@ import { 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 { CreateOrderDto } from "src/orders/@/dtos/create-order.dto"; -import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.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"; @@ -86,78 +84,4 @@ export class OrdersController { workerId: worker.id, }); } - - @EnableAuditLog() - @Post(":id/dishes") - @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(":id/dishes/: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(":id/dishes/: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); - } } diff --git a/src/orders/@/services/order-actions.service.ts b/src/orders/@/services/order-actions.service.ts index b9f6116..f010c69 100644 --- a/src/orders/@/services/order-actions.service.ts +++ b/src/orders/@/services/order-actions.service.ts @@ -9,12 +9,14 @@ import { eq, inArray } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; import { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; +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, ) {} public async getAvailableActions( diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index af3a0b4..687b8f3 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { DrizzleModule } from "@postgress-db/drizzle.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 { OrdersController } from "src/orders/@/orders.controller"; import { OrderActionsService } from "src/orders/@/services/order-actions.service"; import { OrderDishesService } from "src/orders/@/services/order-dishes.service"; @@ -22,6 +23,7 @@ import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders ], controllers: [ OrdersController, + OrderDishesController, DispatcherOrdersController, OrderActionsController, ], From dd489eb0f8c16c5e9cf1428741fd718608a2d5fd Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 18:40:52 +0200 Subject: [PATCH 103/180] feat: force ready order dish endpoint --- src/i18n/messages/en/errors.json | 3 +- src/orders/@/order-dishes.controller.ts | 18 ++++++++++ src/orders/@/services/order-dishes.service.ts | 33 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 2c8783e..caa2e1d 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -80,7 +80,8 @@ "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" + "already-removed": "Dish already removed", + "cant-force-not-cooking-dish": "You can't force ready not cooking dish" }, "order-actions": { "no-dishes-is-pending": "No dishes are pending" diff --git a/src/orders/@/order-dishes.controller.ts b/src/orders/@/order-dishes.controller.ts index ef049c1..d038b3f 100644 --- a/src/orders/@/order-dishes.controller.ts +++ b/src/orders/@/order-dishes.controller.ts @@ -98,4 +98,22 @@ export class OrderDishesController { 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.orderDishesService.forceReady(orderDishId, { + worker, + }); + + return this.ordersService.findById(orderId); + } } diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 31f4c5e..9f1cdc4 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -1,5 +1,6 @@ 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 { orderDishes } from "@postgress-db/schema/order-dishes"; @@ -260,4 +261,36 @@ export class OrderDishesService { return removedOrderDish; } + + public async forceReady( + orderDishId: string, + opts?: { worker?: RequestWorker }, + ) { + const orderDish = await this.getOrderDish(orderDishId); + + if (orderDish.status !== "cooking") { + throw new BadRequestException( + "errors.order-dishes.cant-force-not-cooking-dish", + ); + } + + const updatedOrderDish = await this.pg.transaction(async (tx) => { + const [updatedOrderDish] = await tx + .update(orderDishes) + .set({ status: "ready" }) + .where(eq(orderDishes.id, orderDishId)) + .returning(); + + return updatedOrderDish; + }); + + await this.ordersProducer.dishCrudUpdate({ + action: "UPDATE", + orderDishId: orderDish.id, + orderDish: updatedOrderDish, + calledByWorkerId: opts?.worker?.id, + }); + + return updatedOrderDish; + } } From 72fbc8dedf5f089dd8ce844ffff09d55c50c0c55 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 18:57:36 +0200 Subject: [PATCH 104/180] fix: for dispatcher orders return only not removed order dishes --- src/orders/dispatcher/dispatcher-orders.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/orders/dispatcher/dispatcher-orders.service.ts b/src/orders/dispatcher/dispatcher-orders.service.ts index e8dfd30..a6c6d71 100644 --- a/src/orders/dispatcher/dispatcher-orders.service.ts +++ b/src/orders/dispatcher/dispatcher-orders.service.ts @@ -64,6 +64,7 @@ export class DispatcherOrdersService { }, // Order dishes for statuses orderDishes: { + where: (orderDishes, { eq }) => eq(orderDishes.isRemoved, false), columns: { status: true, }, @@ -132,6 +133,7 @@ export class DispatcherOrdersService { }, // Order dishes for statuses orderDishes: { + where: (orderDishes, { eq }) => eq(orderDishes.isRemoved, false), columns: { status: true, }, @@ -176,6 +178,7 @@ export class DispatcherOrdersService { }, // Order dishes for statuses orderDishes: { + where: (orderDishes, { eq }) => eq(orderDishes.isRemoved, false), columns: { status: true, }, From 20e7440d77b4057f93a1fc27f5b183d96d826753 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Feb 2025 18:59:08 +0200 Subject: [PATCH 105/180] fix: exclude removed dishes from attention required --- src/orders/dispatcher/dispatcher-orders.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/orders/dispatcher/dispatcher-orders.service.ts b/src/orders/dispatcher/dispatcher-orders.service.ts index a6c6d71..593bf01 100644 --- a/src/orders/dispatcher/dispatcher-orders.service.ts +++ b/src/orders/dispatcher/dispatcher-orders.service.ts @@ -113,6 +113,7 @@ export class DispatcherOrdersService { and( eq(orderDishes.orderId, orders.id), eq(orderDishes.status, "pending"), + eq(orderDishes.isRemoved, false), ), ), ), From 8e08c1c91fc2a0ef2c15d950796dc8c857e782f5 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 18 Feb 2025 20:55:40 +0200 Subject: [PATCH 106/180] fix: imports --- src/dishes/@/dishes.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dishes/@/dishes.controller.ts b/src/dishes/@/dishes.controller.ts index 8345295..1f137bd 100644 --- a/src/dishes/@/dishes.controller.ts +++ b/src/dishes/@/dishes.controller.ts @@ -23,13 +23,13 @@ import { ApiOperation, 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"; -import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; @Controller("dishes") @ApiForbiddenResponse({ description: "Forbidden" }) From 47d2dd072828005f056965313438a9383ecb3228 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 19 Feb 2025 16:39:57 +0200 Subject: [PATCH 107/180] feat: assignment of multiple restaurants to workers --- src/@base/drizzle/schema/restaurants.ts | 4 +- src/@base/drizzle/schema/workers.ts | 38 +++- src/@base/snapshots/snapshots.service.ts | 8 +- src/@core/interfaces/request.ts | 7 +- src/@socket/socket.gateway.ts | 2 +- src/@socket/socket.types.ts | 5 +- src/auth/auth.types.ts | 9 +- src/auth/controllers/auth.controller.ts | 12 +- src/auth/services/auth.service.ts | 36 ++-- src/auth/services/sessions.service.ts | 65 +++--- src/workers/dto/req/put-worker.dto.ts | 2 +- src/workers/entities/worker.entity.ts | 51 +++-- src/workers/workers.service.ts | 249 ++++++++++++----------- 13 files changed, 277 insertions(+), 211 deletions(-) diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index aa2edae..a6140dd 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -13,7 +13,7 @@ import { } from "drizzle-orm/pg-core"; import { currencyEnum, dayOfWeekEnum } from "./general"; -import { workers } from "./workers"; +import { workersToRestaurants } from "./workers"; export const restaurants = pgTable("restaurants", { // Primary key @@ -73,7 +73,7 @@ export const restaurantHours = pgTable("restaurantHours", { export const restaurantRelations = relations(restaurants, ({ many }) => ({ restaurantHours: many(restaurantHours), - workers: many(workers), + workersToRestaurants: many(workersToRestaurants), workshops: many(restaurantWorkshops), orders: many(orders), dishesToRestaurants: many(dishesToRestaurants), diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 997d264..b580d63 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -1,9 +1,11 @@ import { orderDeliveries } from "@postgress-db/schema/order-deliveries"; +import { restaurants } from "@postgress-db/schema/restaurants"; import { relations } from "drizzle-orm"; import { boolean, pgEnum, pgTable, + primaryKey, text, timestamp, uuid, @@ -11,7 +13,6 @@ import { import { z } from "zod"; import { workshopWorkers } from "./restaurant-workshop"; -import { restaurants } from "./restaurants"; import { sessions } from "./sessions"; export const workerRoleEnum = pgEnum("workerRoleEnum", [ @@ -32,7 +33,6 @@ export type IRole = (typeof workerRoleEnum.enumValues)[number]; export const workers = pgTable("workers", { id: uuid("id").defaultRandom().primaryKey(), name: text("name").notNull().default("N/A"), - restaurantId: uuid("restaurantId"), login: text("login").unique().notNull(), role: workerRoleEnum("role").notNull(), passwordHash: text("passwordHash").notNull(), @@ -44,11 +44,37 @@ export const workers = pgTable("workers", { updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); -export const workerRelations = relations(workers, ({ one, many }) => ({ - restaurant: one(restaurants, { - fields: [workers.restaurantId], - references: [restaurants.id], +export const workersToRestaurants = pgTable( + "workersToRestaurants", + { + workerId: uuid("workerId").notNull(), + restaurantId: uuid("restaurantId").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), workshops: many(workshopWorkers), deliveries: many(orderDeliveries), diff --git a/src/@base/snapshots/snapshots.service.ts b/src/@base/snapshots/snapshots.service.ts index 27ae9ca..5e72c5c 100644 --- a/src/@base/snapshots/snapshots.service.ts +++ b/src/@base/snapshots/snapshots.service.ts @@ -75,12 +75,18 @@ export class SnapshotsService { 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, - restaurantId: true, createdAt: true, updatedAt: true, firedAt: true, diff --git a/src/@core/interfaces/request.ts b/src/@core/interfaces/request.ts index 419d2a9..cd03e81 100644 --- a/src/@core/interfaces/request.ts +++ b/src/@core/interfaces/request.ts @@ -1,5 +1,5 @@ import { ISession } from "@postgress-db/schema/sessions"; -import { IWorker } from "@postgress-db/schema/workers"; +import { IWorker, IWorkersToRestaurants } from "@postgress-db/schema/workers"; import { Request as Req } from "express"; export type RequestWorker = Pick< @@ -14,8 +14,9 @@ export type RequestWorker = Pick< | "onlineAt" | "createdAt" | "updatedAt" - | "restaurantId" ->; +> & { + workersToRestaurants: Pick[]; +}; export type RequestSession = Pick< ISession, diff --git a/src/@socket/socket.gateway.ts b/src/@socket/socket.gateway.ts index f2dfa17..a02eef6 100644 --- a/src/@socket/socket.gateway.ts +++ b/src/@socket/socket.gateway.ts @@ -359,7 +359,7 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { this.localWorkersMap.set(worker.id, { id: worker.id, role: worker.role, - restaurantId: worker.restaurantId, + restaurants: worker.workersToRestaurants, connectedAt, } satisfies GatewayWorker); diff --git a/src/@socket/socket.types.ts b/src/@socket/socket.types.ts index 98f292a..c69a193 100644 --- a/src/@socket/socket.types.ts +++ b/src/@socket/socket.types.ts @@ -1,4 +1,4 @@ -import { IWorker } from "@postgress-db/schema/workers"; +import { IWorker, IWorkersToRestaurants } from "@postgress-db/schema/workers"; import { OrderEntity } from "src/orders/@/entities/order.entity"; export enum GatewayIncomingMessage { @@ -93,7 +93,8 @@ export interface GatewayClient { export type GatewayClients = GatewayClient[]; -export type GatewayWorker = Pick & { +export type GatewayWorker = Pick & { + restaurants: Pick[]; connectedAt: Date; }; diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts index 0b35ed5..3833ca1 100644 --- a/src/auth/auth.types.ts +++ b/src/auth/auth.types.ts @@ -1,4 +1,4 @@ -import { IWorker } from "@postgress-db/schema/workers"; +import { IWorker, IWorkersToRestaurants } from "@postgress-db/schema/workers"; export enum AUTH_STRATEGY { accessToken = "access token", @@ -12,10 +12,9 @@ export enum AUTH_COOKIES { export type SessionTokenPayload = { sessionId: string; workerId: string; - worker: Pick< - IWorker, - "name" | "restaurantId" | "login" | "role" | "isBlocked" - >; + 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 d670eee..27288f3 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -4,6 +4,7 @@ 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, @@ -20,7 +21,6 @@ import { ApiOperation, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { IWorker } from "@postgress-db/schema/workers"; 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"; @@ -47,8 +47,14 @@ 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() diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 045a1e7..31097ad 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -66,24 +66,22 @@ export class AuthService { } public async getAuthWorker(workerId: string) { - const [worker] = await this.pg - .select({ - id: workers.id, - name: workers.name, - login: workers.login, - role: workers.role, - isBlocked: workers.isBlocked, - hiredAt: workers.hiredAt, - firedAt: workers.firedAt, - onlineAt: workers.onlineAt, - createdAt: workers.createdAt, - updatedAt: workers.updatedAt, - restaurantId: workers.restaurantId, - }) - .from(workers) - .where(eq(workers.id, workerId)) - .limit(1); - - return worker; + 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 index 2420d7d..455a563 100644 --- a/src/auth/services/sessions.service.ts +++ b/src/auth/services/sessions.service.ts @@ -6,7 +6,7 @@ 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, or } from "drizzle-orm"; +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"; @@ -24,16 +24,23 @@ export class SessionsService { ) {} private async _getWorker(workerId: string) { - const [worker] = await this.pg - .select({ - name: workers.name, - restaurantId: workers.restaurantId, - login: workers.login, - role: workers.role, - isBlocked: workers.isBlocked, - }) - .from(workers) - .where(eq(workers.id, workerId)); + 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(); @@ -215,31 +222,21 @@ export class SessionsService { } } - const [session] = await this.pg - .select({ - id: sessions.id, - previousId: sessions.previousId, - workerId: sessions.workerId, - isActive: sessions.isActive, - refreshedAt: sessions.refreshedAt, + const session = await this.pg.query.sessions.findFirst({ + where: (sessions, { eq, or }) => + or(eq(sessions.id, sessionId), eq(sessions.previousId, sessionId)), + with: { worker: { - id: workers.id, - name: workers.name, - login: workers.login, - role: workers.role, - isBlocked: workers.isBlocked, - hiredAt: workers.hiredAt, - firedAt: workers.firedAt, - onlineAt: workers.onlineAt, - createdAt: workers.createdAt, - updatedAt: workers.updatedAt, - restaurantId: workers.restaurantId, + with: { + workersToRestaurants: { + columns: { + restaurantId: true, + }, + }, + }, }, - }) - .from(sessions) - .leftJoin(workers, eq(sessions.workerId, workers.id)) - .where(or(eq(sessions.id, sessionId), eq(sessions.previousId, sessionId))) - .limit(1); + }, + }); if (!session || !session.isActive) return false; diff --git a/src/workers/dto/req/put-worker.dto.ts b/src/workers/dto/req/put-worker.dto.ts index 1e1900b..9fb8f28 100644 --- a/src/workers/dto/req/put-worker.dto.ts +++ b/src/workers/dto/req/put-worker.dto.ts @@ -11,7 +11,7 @@ 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({ diff --git a/src/workers/entities/worker.entity.ts b/src/workers/entities/worker.entity.ts index 3eeb28a..3fa0ad2 100644 --- a/src/workers/entities/worker.entity.ts +++ b/src/workers/entities/worker.entity.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsEnum, IsISO8601, @@ -8,46 +9,58 @@ import { MinLength, } from "@i18n-class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IWorker, ZodWorkerRole } from "@postgress-db/schema/workers"; -import { Exclude, Expose } from "class-transformer"; +import { + IWorker, + IWorkersToRestaurants, + ZodWorkerRole, +} from "@postgress-db/schema/workers"; +import { Exclude, Expose, Type } from "class-transformer"; -export class WorkerEntity implements IWorker { +export class WorkerRestaurantEntity + implements Pick +{ @IsUUID() @Expose() @ApiProperty({ - description: "Unique identifier of the worker", + description: "Unique identifier of the restaurant", example: "d290f1ee-6c54-4b01-90e6-d701748f0851", }) - id: string; + restaurantId: string; @IsString() @Expose() @ApiProperty({ - description: "Name of the worker", - example: "V Keller", + description: "Name of the restaurant", + example: "Restaurant Name", }) - name: string; + restaurantName: string; +} - @IsOptional() +export class WorkerEntity implements IWorker { @IsUUID() @Expose() @ApiProperty({ - description: "Unique identifier of the restaurant", - example: null, + description: "Unique identifier of the worker", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", }) - @ApiPropertyOptional() - restaurantId: string | null; + id: string; - @IsOptional() @IsString() @Expose() @ApiProperty({ - description: "Name of the restaurant where worker is employed", - example: "Restaurant Name", - type: String, + description: "Name of the worker", + example: "V Keller", }) - @ApiPropertyOptional() - restaurantName: string | null; + name: string; + + @IsArray() + @Expose() + @Type(() => WorkerRestaurantEntity) + @ApiProperty({ + description: "Restaurants where worker is employed", + type: [WorkerRestaurantEntity], + }) + restaurants: WorkerRestaurantEntity[]; @IsString() @MinLength(4) diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index 1862c41..3df6d52 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -9,7 +9,7 @@ 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 { and, count, eq, 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"; @@ -57,41 +57,47 @@ export class WorkersService implements OnApplicationBootstrap { }): Promise { const { pagination, sorting, filters } = options; - const query = this.pg - .select({ - id: schema.workers.id, - name: schema.workers.name, - login: schema.workers.login, - role: schema.workers.role, - isBlocked: schema.workers.isBlocked, - hiredAt: schema.workers.hiredAt, - firedAt: schema.workers.firedAt, - onlineAt: schema.workers.onlineAt, - createdAt: schema.workers.createdAt, - updatedAt: schema.workers.updatedAt, - restaurantId: schema.workers.restaurantId, - restaurantName: schema.restaurants.name, - passwordHash: schema.workers.passwordHash, - }) - .from(schema.workers) - .leftJoin( - schema.restaurants, - eq(schema.workers.restaurantId, schema.restaurants.id), - ); - - if (filters) { - query.where(DrizzleUtils.buildFilterConditions(schema.workers, filters)); - } - - if (sorting) { - query.orderBy( - sorting.sortOrder === "asc" - ? asc(sql.identifier(sorting.sortBy)) - : desc(sql.identifier(sorting.sortBy)), - ); - } - - return await query.limit(pagination.size).offset(pagination.offset); + const workers = await this.pg.query.workers.findMany({ + ...(filters + ? { + where: () => + and(DrizzleUtils.buildFilterConditions(schema.workers, filters)), + } + : {}), + with: { + workersToRestaurants: { + with: { + restaurant: { + columns: { + name: true, + }, + }, + }, + columns: { + restaurantId: true, + }, + }, + }, + ...(sorting + ? { + orderBy: (workers, { asc, desc }) => [ + sorting.sortOrder === "asc" + ? asc(sql.identifier(sorting.sortBy)) + : desc(sql.identifier(sorting.sortBy)), + ], + } + : {}), + limit: pagination.size, + offset: pagination.offset, + }); + + return workers.map((w) => ({ + ...w, + restaurants: w.workersToRestaurants.map((r) => ({ + restaurantId: r.restaurantId, + restaurantName: r.restaurant.name, + })), + })); } /** @@ -100,31 +106,33 @@ export class WorkersService implements OnApplicationBootstrap { * @returns */ public async findById(id: string): Promise { - const result = await this.pg - .select({ - id: schema.workers.id, - name: schema.workers.name, - login: schema.workers.login, - role: schema.workers.role, - isBlocked: schema.workers.isBlocked, - hiredAt: schema.workers.hiredAt, - firedAt: schema.workers.firedAt, - onlineAt: schema.workers.onlineAt, - createdAt: schema.workers.createdAt, - updatedAt: schema.workers.updatedAt, - restaurantId: schema.workers.restaurantId, - restaurantName: schema.restaurants.name, - passwordHash: schema.workers.passwordHash, - }) - .from(schema.workers) - .leftJoin( - schema.restaurants, - eq(schema.workers.restaurantId, schema.restaurants.id), - ) - .where(eq(schema.workers.id, id)) - .limit(1); - - return result[0]; + 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, + })), + }; } /** @@ -135,31 +143,16 @@ export class WorkersService implements OnApplicationBootstrap { public async findOneByLogin( value: string, ): Promise { - const result = await this.pg - .select({ - id: schema.workers.id, - name: schema.workers.name, - login: schema.workers.login, - role: schema.workers.role, - isBlocked: schema.workers.isBlocked, - hiredAt: schema.workers.hiredAt, - firedAt: schema.workers.firedAt, - onlineAt: schema.workers.onlineAt, - createdAt: schema.workers.createdAt, - updatedAt: schema.workers.updatedAt, - restaurantId: schema.workers.restaurantId, - restaurantName: schema.restaurants.name, - passwordHash: schema.workers.passwordHash, - }) - .from(schema.workers) - .leftJoin( - schema.restaurants, - eq(schema.workers.restaurantId, schema.restaurants.id), - ) - .where(eq(schema.workers.login, value)) - .limit(1); - - return result[0]; + 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); } /** @@ -168,21 +161,33 @@ export class WorkersService implements OnApplicationBootstrap { * @returns */ public async create(dto: CreateWorkerDto): Promise { - const { password, role, restaurantId, ...rest } = dto; + const { password, role, restaurants, ...rest } = dto; - if (restaurantId) { + if (restaurants?.length) { this.checkRestaurantRoleAssignment(role); } - const workers = await this.pg - .insert(schema.workers) - .values({ - ...rest, - // restaurantId, - role, - passwordHash: await argon2.hash(password), - }) - .returning(); + 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]; @@ -203,7 +208,7 @@ export class WorkersService implements OnApplicationBootstrap { id: string, dto: UpdateWorkerDto, ): Promise { - const { password, role, login, restaurantId, ...payload } = dto; + const { password, role, login, restaurants, ...payload } = dto; if (login) { const exist = await this.findOneByLogin(login); @@ -222,7 +227,7 @@ export class WorkersService implements OnApplicationBootstrap { } } - if (restaurantId) { + if (restaurants?.length) { this.checkRestaurantRoleAssignment(role); } @@ -232,22 +237,36 @@ export class WorkersService implements OnApplicationBootstrap { ); } - 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); } From bc1e0ec678d6c87223d2061ae2d1ba8ee7786922 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 19 Feb 2025 17:02:01 +0200 Subject: [PATCH 108/180] feat: restaurantIds filter for workers --- src/workers/workers.controller.ts | 21 +++++++++- src/workers/workers.service.ts | 66 +++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index 96dc025..b5ef1ab 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -11,7 +11,7 @@ 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 { Body, Get, Param, Post, Put } from "@nestjs/common"; +import { Body, Get, Param, Post, Put, Query } from "@nestjs/common"; import { ApiBadRequestResponse, ApiConflictResponse, @@ -20,6 +20,7 @@ import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiQuery, ApiUnauthorizedResponse, } from "@nestjs/swagger"; import { @@ -43,6 +44,13 @@ export class WorkersController { @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", @@ -63,12 +71,21 @@ export class WorkersController { sorting: ISorting, @PaginationParams() pagination: IPagination, @FilterParams() filters?: IFilters, + @Query("restaurantIds") restaurantIds?: string, ): Promise { - const total = await this.workersService.getTotalCount(filters); + 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 { diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index 3df6d52..01224fe 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -9,7 +9,7 @@ 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 { and, count, 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"; @@ -40,11 +40,38 @@ export class WorkersService implements OnApplicationBootstrap { return true; } - public async getTotalCount(filters?: IFilters): Promise { + public async getTotalCount( + filters?: IFilters, + restaurantIds?: string[], + ): Promise { const query = this.pg.select({ value: count() }).from(schema.workers); - if (filters) { - query.where(DrizzleUtils.buildFilterConditions(schema.workers, filters)); + 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); @@ -54,14 +81,39 @@ export class WorkersService implements OnApplicationBootstrap { pagination: IPagination; sorting: ISorting; filters?: IFilters; + restaurantIds?: string[]; }): Promise { - const { pagination, sorting, filters } = options; + const { pagination, sorting, filters, restaurantIds } = options; + + 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)); + } const workers = await this.pg.query.workers.findMany({ - ...(filters + ...(conditions.length > 0 ? { where: () => - and(DrizzleUtils.buildFilterConditions(schema.workers, filters)), + conditions.length > 1 ? or(...conditions) : conditions[0], } : {}), with: { From a575f2bdd8e1ded2e10b725358c0aacc854b5793 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 19 Feb 2025 17:19:48 +0200 Subject: [PATCH 109/180] feat: ownerId field for restaurants --- src/@base/drizzle/schema/restaurants.ts | 11 +++++++++-- src/@base/drizzle/schema/workers.ts | 3 +++ src/restaurants/@/dto/create-restaurant.dto.ts | 1 + src/restaurants/@/entities/restaurant.entity.ts | 11 ++++++++++- src/workers/entities/worker.entity.ts | 1 + 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index a6140dd..619e5b1 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -13,7 +13,7 @@ import { } from "drizzle-orm/pg-core"; import { currencyEnum, dayOfWeekEnum } from "./general"; -import { workersToRestaurants } from "./workers"; +import { workers, workersToRestaurants } from "./workers"; export const restaurants = pgTable("restaurants", { // Primary key @@ -45,6 +45,9 @@ export const restaurants = pgTable("restaurants", { // Is closed forever? // isClosedForever: boolean("isClosedForever").notNull().default(false), + // Owner of the restaurant // + ownerId: uuid("ownerId"), + // Timestamps // createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), @@ -71,13 +74,17 @@ export const restaurantHours = pgTable("restaurantHours", { updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); -export const restaurantRelations = relations(restaurants, ({ many }) => ({ +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], + }), })); export const restaurantHourRelations = relations( diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index b580d63..65a1aec 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -18,6 +18,7 @@ import { sessions } from "./sessions"; export const workerRoleEnum = pgEnum("workerRoleEnum", [ "SYSTEM_ADMIN" as const, "CHIEF_ADMIN", + "OWNER", "ADMIN", "KITCHENER", "WAITER", @@ -78,6 +79,7 @@ export const workerRelations = relations(workers, ({ many }) => ({ sessions: many(sessions), workshops: many(workshopWorkers), deliveries: many(orderDeliveries), + ownedRestaurants: many(restaurants), })); export type IWorker = typeof workers.$inferSelect; @@ -85,6 +87,7 @@ export type WorkerRole = typeof ZodWorkerRole._type; export const workerRoleRank: Record = { KITCHENER: 0, + OWNER: 0, WAITER: 0, CASHIER: 0, DISPATCHER: 0, diff --git a/src/restaurants/@/dto/create-restaurant.dto.ts b/src/restaurants/@/dto/create-restaurant.dto.ts index 1629656..c3476e3 100644 --- a/src/restaurants/@/dto/create-restaurant.dto.ts +++ b/src/restaurants/@/dto/create-restaurant.dto.ts @@ -11,4 +11,5 @@ export class CreateRestaurantDto extends PickType(RestaurantEntity, [ "timezone", "isEnabled", "isClosedForever", + "ownerId", ]) {} diff --git a/src/restaurants/@/entities/restaurant.entity.ts b/src/restaurants/@/entities/restaurant.entity.ts index b287660..5545df4 100644 --- a/src/restaurants/@/entities/restaurant.entity.ts +++ b/src/restaurants/@/entities/restaurant.entity.ts @@ -7,7 +7,7 @@ import { IsString, IsUUID, } from "@i18n-class-validator"; -import { ApiProperty } from "@nestjs/swagger"; +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"; @@ -106,6 +106,15 @@ export class RestaurantEntity implements IRestaurant { }) 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/workers/entities/worker.entity.ts b/src/workers/entities/worker.entity.ts index 3fa0ad2..55a8dd9 100644 --- a/src/workers/entities/worker.entity.ts +++ b/src/workers/entities/worker.entity.ts @@ -79,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), }) From fb37c79f66226e8993f09d1cb1bd8c10b1e8b839 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 19 Feb 2025 17:26:10 +0200 Subject: [PATCH 110/180] feat: if owner creates restaurant assign it's id --- src/workers/workers.controller.ts | 5 +++-- src/workers/workers.service.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index b5ef1ab..317fbcc 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -11,6 +11,7 @@ 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, @@ -109,7 +110,7 @@ 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]; @@ -130,7 +131,7 @@ export class WorkersController { ); } - return await this.workersService.create(data); + return await this.workersService.create(data, { worker }); } @EnableAuditLog({ onlyErrors: true }) diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index 01224fe..d059087 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -5,6 +5,7 @@ import env from "@core/env"; import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; import { ConflictException } from "@core/errors/exceptions/conflict.exception"; import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; +import { RequestWorker } from "@core/interfaces/request"; import { Inject, Injectable, OnApplicationBootstrap } from "@nestjs/common"; import { schema } from "@postgress-db/drizzle.module"; import { IWorker } from "@postgress-db/schema/workers"; @@ -212,8 +213,12 @@ export class WorkersService implements OnApplicationBootstrap { * @param dto * @returns */ - public async create(dto: CreateWorkerDto): Promise { + public async create( + dto: CreateWorkerDto, + opts?: { worker?: RequestWorker }, + ): Promise { const { password, role, restaurants, ...rest } = dto; + const requestWorker = opts?.worker; if (restaurants?.length) { this.checkRestaurantRoleAssignment(role); @@ -224,6 +229,9 @@ export class WorkersService implements OnApplicationBootstrap { .insert(schema.workers) .values({ ...rest, + ...(requestWorker?.role === "OWNER" && { + ownerId: requestWorker.id, + }), role, passwordHash: await argon2.hash(password), }) From c816b11284b2aa4e7492ff025bbc9646fc23c3c5 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 19 Feb 2025 17:50:03 +0200 Subject: [PATCH 111/180] feat: secure fetch of restaurants for different roles --- .../@/controllers/restaurants.controller.ts | 31 ++++++-- .../@/services/restaurants.service.ts | 78 +++++++++++++++++-- src/workers/workers.controller.ts | 2 +- src/workers/workers.service.ts | 10 +-- 4 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts index e4979a4..abb9f17 100644 --- a/src/restaurants/@/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -5,6 +5,8 @@ import { } from "@core/decorators/pagination.decorator"; import { Roles } from "@core/decorators/roles.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, Post, Put } from "@nestjs/common"; import { ApiCreatedResponse, @@ -39,9 +41,15 @@ export class RestaurantsController { description: "Restaurants have been successfully fetched", type: RestaurantsPaginatedDto, }) - async findAll(@PaginationParams() pagination: IPagination) { + async findAll( + @PaginationParams() pagination: IPagination, + @Worker() worker: RequestWorker, + ) { const total = await this.restaurantsService.getTotalCount(); - const data = await this.restaurantsService.findMany({ pagination }); + const data = await this.restaurantsService.findMany({ + pagination, + worker, + }); return { data, @@ -54,7 +62,6 @@ export class RestaurantsController { @EnableAuditLog() @Post() - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @Serializable(RestaurantEntity) @ApiOperation({ summary: "Creates a new restaurant", @@ -66,8 +73,13 @@ export class RestaurantsController { @ApiForbiddenResponse({ description: "Action available only for SYSTEM_ADMIN, CHIEF_ADMIN", }) - async create(@Body() dto: CreateRestaurantDto): Promise { - return await this.restaurantsService.create(dto); + async create( + @Body() dto: CreateRestaurantDto, + @Worker() worker: RequestWorker, + ): Promise { + return await this.restaurantsService.create(dto, { + worker, + }); } @Get(":id") @@ -82,8 +94,13 @@ export class RestaurantsController { @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, + }); } @EnableAuditLog() diff --git a/src/restaurants/@/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts index 2ca74ba..8684ca8 100644 --- a/src/restaurants/@/services/restaurants.service.ts +++ b/src/restaurants/@/services/restaurants.service.ts @@ -1,9 +1,10 @@ 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 { count, eq } from "drizzle-orm"; +import { and, count, eq, 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"; @@ -37,10 +38,36 @@ export class RestaurantsService { */ public async findMany(options: { pagination: IPagination; + worker?: RequestWorker; }): Promise { + const { pagination, worker } = 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), + ), + ); + } + } + return await this.pg.query.restaurants.findMany({ - limit: options.pagination.size, - offset: options.pagination.offset, + ...(conditions.length > 0 ? { where: () => and(...conditions) } : {}), + limit: pagination.size, + offset: pagination.offset, }); } @@ -49,9 +76,38 @@ export class RestaurantsService { * @param id * @returns */ - public async findById(id: string): Promise { + 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: eq(schema.restaurants.id, id), + where: and(...conditions), }); if (!data) { @@ -66,7 +122,12 @@ export class RestaurantsService { * @param dto * @returns */ - public async create(dto: CreateRestaurantDto): Promise { + 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", @@ -78,7 +139,10 @@ export class RestaurantsService { const data = await this.pg .insert(schema.restaurants) - .values(dto) + .values({ + ...dto, + ...(requestWorker?.role === "OWNER" && { ownerId: requestWorker.id }), + }) .returning({ id: schema.restaurants.id, }); diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index 317fbcc..8a038ac 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -131,7 +131,7 @@ export class WorkersController { ); } - return await this.workersService.create(data, { worker }); + return await this.workersService.create(data); } @EnableAuditLog({ onlyErrors: true }) diff --git a/src/workers/workers.service.ts b/src/workers/workers.service.ts index d059087..01224fe 100644 --- a/src/workers/workers.service.ts +++ b/src/workers/workers.service.ts @@ -5,7 +5,6 @@ import env from "@core/env"; import { BadRequestException } from "@core/errors/exceptions/bad-request.exception"; import { ConflictException } from "@core/errors/exceptions/conflict.exception"; import { ServerErrorException } from "@core/errors/exceptions/server-error.exception"; -import { RequestWorker } from "@core/interfaces/request"; import { Inject, Injectable, OnApplicationBootstrap } from "@nestjs/common"; import { schema } from "@postgress-db/drizzle.module"; import { IWorker } from "@postgress-db/schema/workers"; @@ -213,12 +212,8 @@ export class WorkersService implements OnApplicationBootstrap { * @param dto * @returns */ - public async create( - dto: CreateWorkerDto, - opts?: { worker?: RequestWorker }, - ): Promise { + public async create(dto: CreateWorkerDto): Promise { const { password, role, restaurants, ...rest } = dto; - const requestWorker = opts?.worker; if (restaurants?.length) { this.checkRestaurantRoleAssignment(role); @@ -229,9 +224,6 @@ export class WorkersService implements OnApplicationBootstrap { .insert(schema.workers) .values({ ...rest, - ...(requestWorker?.role === "OWNER" && { - ownerId: requestWorker.id, - }), role, passwordHash: await argon2.hash(password), }) From eae9f95090a2a1d23537a7d3db6b027cfb92599a Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 19 Feb 2025 18:41:42 +0200 Subject: [PATCH 112/180] feat: backend payment methods cru(d) for restaurants --- src/@base/drizzle/drizzle.module.ts | 2 + src/@base/drizzle/schema/payment-methods.ts | 6 +- src/app.module.ts | 2 + src/i18n/messages/en/errors.json | 5 + .../dto/create-payment-method.dto.ts | 25 ++++ .../dto/update-payment-method.dto.ts | 9 ++ .../entities/payment-method.entity.ts | 112 +++++++++++++++ .../payment-methods.controller.ts | 75 ++++++++++ src/payment-methods/payment-methods.module.ts | 13 ++ .../payment-methods.service.ts | 130 ++++++++++++++++++ 10 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 src/payment-methods/dto/create-payment-method.dto.ts create mode 100644 src/payment-methods/dto/update-payment-method.dto.ts create mode 100644 src/payment-methods/entities/payment-method.entity.ts create mode 100644 src/payment-methods/payment-methods.controller.ts create mode 100644 src/payment-methods/payment-methods.module.ts create mode 100644 src/payment-methods/payment-methods.service.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index 505c37c..4faaaea 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -15,6 +15,7 @@ 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"; @@ -34,6 +35,7 @@ export const schema = { ...orderDishes, ...orderDeliveries, ...orders, + ...paymentMethods, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/payment-methods.ts b/src/@base/drizzle/schema/payment-methods.ts index 319b2ba..75a701c 100644 --- a/src/@base/drizzle/schema/payment-methods.ts +++ b/src/@base/drizzle/schema/payment-methods.ts @@ -31,18 +31,20 @@ export const paymentMethods = pgTable("paymentMethods", { type: paymentMethodTypeEnum("type").notNull(), icon: paymentMethodIconEnum("icon").notNull(), - restaurantId: uuid("restaurantId"), + restaurantId: uuid("restaurantId").notNull(), // For YOO_KASSA // - shopId: text("shopId"), + secretId: text("secretId"), secretKey: text("secretKey"), // Boolean fields // isActive: boolean("isActive").notNull().default(false), + isRemoved: boolean("isRemoved").notNull().default(false), // Default timestamps // createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), + removedAt: timestamp("removedAt"), }); export type IPaymentMethod = typeof paymentMethods.$inferSelect; diff --git a/src/app.module.ts b/src/app.module.ts index d88df41..9024aaf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,6 +30,7 @@ import { DishesModule } from "src/dishes/dishes.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 { TimezonesModule } from "src/timezones/timezones.module"; import { DrizzleModule } from "./@base/drizzle/drizzle.module"; @@ -96,6 +97,7 @@ import { WorkersModule } from "./workers/workers.module"; ], }), SocketModule, + PaymentMethodsModule, ], providers: [ { diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index caa2e1d..bea1923 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -85,5 +85,10 @@ }, "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" } } 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..a64559d --- /dev/null +++ b/src/payment-methods/payment-methods.controller.ts @@ -0,0 +1,75 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { 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"; + +@Controller("restaurants/:id/payment-methods", { + tags: ["restaurants"], +}) +export class PaymentMethodsController { + constructor(private readonly paymentMethodsService: PaymentMethodsService) {} + + @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, + }); + } + + @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, + }, + ); + } + + @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(); + } +} From dee318f3a3846d94e612d1acddaf80855f73f60d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 19 Feb 2025 19:00:48 +0200 Subject: [PATCH 113/180] feat: drizzle model for dish modifiers --- src/@base/drizzle/schema/dish-modifiers.ts | 69 ++++++++++++++++++++++ src/@base/drizzle/schema/order-dishes.ts | 4 +- src/@base/drizzle/schema/restaurants.ts | 2 + 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/@base/drizzle/schema/dish-modifiers.ts diff --git a/src/@base/drizzle/schema/dish-modifiers.ts b/src/@base/drizzle/schema/dish-modifiers.ts new file mode 100644 index 0000000..a44a362 --- /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("dishModifiers", { + id: uuid("id").defaultRandom().primaryKey(), + + // Modifier data // + name: text("name").notNull(), + + // Modifiers should be linked to a restaurant // + restaurantId: uuid("restaurantId").notNull(), + + // Boolean fields // + isActive: boolean("isActive").notNull().default(true), + isRemoved: boolean("isRemoved").notNull().default(false), + + // Default timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), + removedAt: timestamp("removedAt"), +}); + +export type IDishModifier = typeof dishModifiers.$inferSelect; + +export const dishModifiersToOrderDishes = pgTable( + "dishModifiersToOrderDishes", + { + dishModifierId: uuid("dishModifierId"), + orderDishId: uuid("orderDishId"), + }, + (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/order-dishes.ts b/src/@base/drizzle/schema/order-dishes.ts index c0071c2..0e75227 100644 --- a/src/@base/drizzle/schema/order-dishes.ts +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -1,3 +1,4 @@ +import { dishModifiersToOrderDishes } from "@postgress-db/schema/dish-modifiers"; import { dishes } from "@postgress-db/schema/dishes"; import { orders } from "@postgress-db/schema/orders"; import { relations } from "drizzle-orm"; @@ -74,7 +75,7 @@ export const orderDishes = pgTable( export type IOrderDish = typeof orderDishes.$inferSelect; -export const orderDishRelations = relations(orderDishes, ({ one }) => ({ +export const orderDishRelations = relations(orderDishes, ({ one, many }) => ({ order: one(orders, { fields: [orderDishes.orderId], references: [orders.id], @@ -83,4 +84,5 @@ export const orderDishRelations = relations(orderDishes, ({ one }) => ({ fields: [orderDishes.dishId], references: [dishes.id], }), + dishModifiersToOrderDishes: many(dishModifiersToOrderDishes), })); diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 619e5b1..764548e 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -1,3 +1,4 @@ +import { dishModifiers } from "@postgress-db/schema/dish-modifiers"; import { dishesToRestaurants } from "@postgress-db/schema/dishes"; import { orders } from "@postgress-db/schema/orders"; import { paymentMethods } from "@postgress-db/schema/payment-methods"; @@ -85,6 +86,7 @@ export const restaurantRelations = relations(restaurants, ({ one, many }) => ({ fields: [restaurants.ownerId], references: [workers.id], }), + dishModifiers: many(dishModifiers), })); export const restaurantHourRelations = relations( From 4c70da3dea870a1d4d808f15025ee9defd7d52b6 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 21 Feb 2025 16:06:46 +0200 Subject: [PATCH 114/180] feat: restaurant dish modifiers --- src/@base/drizzle/drizzle.module.ts | 2 + src/@base/drizzle/schema/workers.ts | 9 ++ src/@core/guards/roles.guard.ts | 37 ----- src/@core/interfaces/request.ts | 2 + src/app.module.ts | 10 +- src/auth/services/sessions.service.ts | 5 + .../@/controllers/restaurants.controller.ts | 16 ++- .../decorators/restaurant-guard.decorator.ts | 18 +++ src/restaurants/@/guards/restaurant.guard.ts | 66 +++++++++ .../create-restaurant-dish-modifier.dto.ts | 7 + .../update-restraurant-dish-modifier.dto.ts | 7 + .../restaurant-dish-modifier.entity.ts | 77 ++++++++++ .../restaurant-dish-modifiers.controller.ts | 116 +++++++++++++++ .../restaurant-dish-modifiers.service.ts | 135 ++++++++++++++++++ .../hours/restaurant-hours.controller.ts | 21 ++- src/restaurants/restaurants.module.ts | 4 + .../restaurant-workshops.controller.ts | 30 +++- src/workers/workers.controller.ts | 3 - 18 files changed, 508 insertions(+), 57 deletions(-) delete mode 100644 src/@core/guards/roles.guard.ts create mode 100644 src/restaurants/@/decorators/restaurant-guard.decorator.ts create mode 100644 src/restaurants/@/guards/restaurant.guard.ts create mode 100644 src/restaurants/dish-modifiers/dto/create-restaurant-dish-modifier.dto.ts create mode 100644 src/restaurants/dish-modifiers/dto/update-restraurant-dish-modifier.dto.ts create mode 100644 src/restaurants/dish-modifiers/entities/restaurant-dish-modifier.entity.ts create mode 100644 src/restaurants/dish-modifiers/restaurant-dish-modifiers.controller.ts create mode 100644 src/restaurants/dish-modifiers/restaurant-dish-modifiers.service.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index 4faaaea..af3f4e6 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -7,6 +7,7 @@ import { Pool } from "pg"; import { PG_CONNECTION } from "../../constants"; import * as dishCategories from "./schema/dish-categories"; +import * as dishModifiers from "./schema/dish-modifiers"; import * as dishes from "./schema/dishes"; import * as files from "./schema/files"; import * as general from "./schema/general"; @@ -36,6 +37,7 @@ export const schema = { ...orderDeliveries, ...orders, ...paymentMethods, + ...dishModifiers, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 65a1aec..645a0d7 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -30,6 +30,15 @@ export const workerRoleEnum = pgEnum("workerRoleEnum", [ 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(), diff --git a/src/@core/guards/roles.guard.ts b/src/@core/guards/roles.guard.ts deleted file mode 100644 index a78bbc8..0000000 --- a/src/@core/guards/roles.guard.ts +++ /dev/null @@ -1,37 +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/workers"; - -@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( - "errors.common.forbidden-access-to-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 cd03e81..b9a6458 100644 --- a/src/@core/interfaces/request.ts +++ b/src/@core/interfaces/request.ts @@ -1,3 +1,4 @@ +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"; @@ -16,6 +17,7 @@ export type RequestWorker = Pick< | "updatedAt" > & { workersToRestaurants: Pick[]; + ownedRestaurants: Pick[]; }; export type RequestSession = Pick< diff --git a/src/app.module.ts b/src/app.module.ts index 9024aaf..e61f885 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,6 @@ import * as path from "path"; import env from "@core/env"; -import { RolesGuard } from "@core/guards/roles.guard"; import { RedisModule } from "@liaoliaots/nestjs-redis"; import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; @@ -31,6 +30,7 @@ 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 { DrizzleModule } from "./@base/drizzle/drizzle.module"; @@ -108,14 +108,14 @@ import { WorkersModule } from "./workers/workers.module"; provide: APP_GUARD, useClass: SessionAuthGuard, }, - { - provide: APP_GUARD, - useClass: RolesGuard, - }, { provide: APP_INTERCEPTOR, useClass: AuditLogsInterceptor, }, + { + provide: APP_GUARD, + useClass: RestaurantGuard, + }, ], }) export class AppModule {} diff --git a/src/auth/services/sessions.service.ts b/src/auth/services/sessions.service.ts index 455a563..72e7c48 100644 --- a/src/auth/services/sessions.service.ts +++ b/src/auth/services/sessions.service.ts @@ -233,6 +233,11 @@ export class SessionsService { restaurantId: true, }, }, + ownedRestaurants: { + columns: { + id: true, + }, + }, }, }, }, diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts index abb9f17..0a557c0 100644 --- a/src/restaurants/@/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -3,7 +3,6 @@ import { IPagination, PaginationParams, } from "@core/decorators/pagination.decorator"; -import { Roles } from "@core/decorators/roles.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; import { Worker } from "@core/decorators/worker.decorator"; import { RequestWorker } from "@core/interfaces/request"; @@ -18,6 +17,7 @@ import { ApiUnauthorizedResponse, } from "@nestjs/swagger"; import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { RestaurantGuard } from "src/restaurants/@/decorators/restaurant-guard.decorator"; import { CreateRestaurantDto } from "../dto/create-restaurant.dto"; import { UpdateRestaurantDto } from "../dto/update-restaurant.dto"; @@ -82,6 +82,10 @@ export class RestaurantsController { }); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) @Get(":id") @Serializable(RestaurantEntity) @ApiOperation({ @@ -103,9 +107,12 @@ export class RestaurantsController { }); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) @EnableAuditLog() @Put(":id") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @Serializable(RestaurantEntity) @ApiOperation({ summary: "Updates restaurant by id", @@ -127,9 +134,12 @@ export class RestaurantsController { return await this.restaurantsService.update(id, dto); } + @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/@/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/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..1fd8ad8 --- /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"] 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/hours/restaurant-hours.controller.ts b/src/restaurants/hours/restaurant-hours.controller.ts index e315c41..add8400 100644 --- a/src/restaurants/hours/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 { @@ -10,6 +9,7 @@ import { 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 { CreateRestaurantHoursDto, @@ -31,6 +31,10 @@ export class RestaurantHoursController { private readonly restaurantHoursService: RestaurantHoursService, ) {} + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) @EnableAuditLog({ onlyErrors: true }) @Get() @Serializable(RestaurantHoursEntity) @@ -43,9 +47,12 @@ export class RestaurantHoursController { 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", @@ -63,9 +70,12 @@ export class RestaurantHoursController { }); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) @EnableAuditLog() @Put(":hoursId") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @Serializable(RestaurantHoursEntity) @ApiOperation({ summary: "Updates restaurant hours" }) @ApiOkResponse({ @@ -81,9 +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/restaurants.module.ts b/src/restaurants/restaurants.module.ts index f84f4b4..b593d1c 100644 --- a/src/restaurants/restaurants.module.ts +++ b/src/restaurants/restaurants.module.ts @@ -4,6 +4,8 @@ import { TimezonesModule } from "src/timezones/timezones.module"; 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"; @@ -15,11 +17,13 @@ import { RestaurantWorkshopsService } from "./workshops/restaurant-workshops.ser RestaurantsService, RestaurantHoursService, RestaurantWorkshopsService, + RestaurantDishModifiersService, ], controllers: [ RestaurantsController, RestaurantHoursController, RestaurantWorkshopsController, + RestaurantDishModifiersController, ], exports: [ RestaurantsService, diff --git a/src/restaurants/workshops/restaurant-workshops.controller.ts b/src/restaurants/workshops/restaurant-workshops.controller.ts index 8134418..42ace92 100644 --- a/src/restaurants/workshops/restaurant-workshops.controller.ts +++ b/src/restaurants/workshops/restaurant-workshops.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 { @@ -10,6 +9,7 @@ import { 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"; @@ -33,6 +33,10 @@ export class RestaurantWorkshopsController { private readonly restaurantWorkshopsService: RestaurantWorkshopsService, ) {} + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN", "KITCHENER", "WAITER", "CASHIER"], + }) @EnableAuditLog({ onlyErrors: true }) @Get() @Serializable(RestaurantWorkshopDto) @@ -45,9 +49,12 @@ export class RestaurantWorkshopsController { return await this.restaurantWorkshopsService.findMany(id); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) @EnableAuditLog() @Post() - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Creates restaurant workshop" }) @ApiCreatedResponse({ description: "Restaurant workshop has been successfully created", @@ -65,9 +72,12 @@ export class RestaurantWorkshopsController { }); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) @EnableAuditLog() @Put(":workshopId") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @Serializable(RestaurantWorkshopDto) @ApiOperation({ summary: "Updates restaurant workshop" }) @ApiOkResponse({ @@ -83,6 +93,10 @@ export class RestaurantWorkshopsController { 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) @@ -95,9 +109,12 @@ export class RestaurantWorkshopsController { return await this.restaurantWorkshopsService.getWorkers(id); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) @EnableAuditLog() @Put(":workshopId/workers") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Updates workshop workers" }) @ApiOkResponse({ description: "Workshop workers have been successfully updated", @@ -113,9 +130,12 @@ export class RestaurantWorkshopsController { return; } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) @EnableAuditLog() @Delete(":workshopId") - @Roles("SYSTEM_ADMIN", "CHIEF_ADMIN") @ApiOperation({ summary: "Deletes restaurant workshop" }) @ApiOkResponse({ description: "Restaurant workshop has been successfully deleted", diff --git a/src/workers/workers.controller.ts b/src/workers/workers.controller.ts index 8a038ac..fe55aa3 100644 --- a/src/workers/workers.controller.ts +++ b/src/workers/workers.controller.ts @@ -4,7 +4,6 @@ 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"; @@ -101,7 +100,6 @@ 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" }) @@ -167,7 +165,6 @@ export class WorkersController { // 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({ From 22547d7e917ebdaf75cbe8ca8c2815615abfb298 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 21 Feb 2025 16:41:26 +0200 Subject: [PATCH 115/180] feat: orders and payment method relation --- src/@base/drizzle/schema/orders.ts | 6 ++++++ src/@base/drizzle/schema/payment-methods.ts | 15 ++++++++++----- src/orders/@/entities/order.entity.ts | 9 +++++++++ .../@/controllers/restaurants.controller.ts | 2 ++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts index d91d5e0..65572a2 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -2,6 +2,7 @@ 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 { @@ -66,6 +67,7 @@ export const orders = pgTable( // Links // guestId: uuid("guestId"), restaurantId: uuid("restaurantId"), + paymentMethodId: uuid("paymentMethodId"), // Order number // number: text("number").notNull(), @@ -124,6 +126,10 @@ export const orders = pgTable( 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], diff --git a/src/@base/drizzle/schema/payment-methods.ts b/src/@base/drizzle/schema/payment-methods.ts index 75a701c..f44f1ee 100644 --- a/src/@base/drizzle/schema/payment-methods.ts +++ b/src/@base/drizzle/schema/payment-methods.ts @@ -1,3 +1,4 @@ +import { orders } from "@postgress-db/schema/orders"; import { restaurants } from "@postgress-db/schema/restaurants"; import { relations } from "drizzle-orm"; import { @@ -49,9 +50,13 @@ export const paymentMethods = pgTable("paymentMethods", { export type IPaymentMethod = typeof paymentMethods.$inferSelect; -export const paymentMethodRelations = relations(paymentMethods, ({ one }) => ({ - restaurant: one(restaurants, { - fields: [paymentMethods.restaurantId], - references: [restaurants.id], +export const paymentMethodRelations = relations( + paymentMethods, + ({ one, many }) => ({ + orders: many(orders), + restaurant: one(restaurants, { + fields: [paymentMethods.restaurantId], + references: [restaurants.id], + }), }), -})); +); diff --git a/src/orders/@/entities/order.entity.ts b/src/orders/@/entities/order.entity.ts index 0a3d64f..9d435b5 100644 --- a/src/orders/@/entities/order.entity.ts +++ b/src/orders/@/entities/order.entity.ts @@ -49,6 +49,15 @@ export class OrderEntity implements IOrder { }) restaurantId: string | null; + @IsUUID() + @IsOptional() + @Expose() + @ApiPropertyOptional({ + description: "Payment method identifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + paymentMethodId: string | null; + @IsString() @IsOptional() @Expose() diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts index 0a557c0..eb6653f 100644 --- a/src/restaurants/@/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -31,6 +31,7 @@ import { RestaurantsService } from "../services/restaurants.service"; export class RestaurantsController { constructor(private readonly restaurantsService: RestaurantsService) {} + // TODO: configure custom guard for this endpoint @EnableAuditLog({ onlyErrors: true }) @Get() @ApiOperation({ @@ -60,6 +61,7 @@ export class RestaurantsController { }; } + // TODO: configure custom guard for this endpoint @EnableAuditLog() @Post() @Serializable(RestaurantEntity) From cef90d4bfb66e7c66b50581a8ce1ee21dd160057 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 21 Feb 2025 18:33:50 +0200 Subject: [PATCH 116/180] feat: guard for payment methods --- src/orders/@/dtos/create-order.dto.ts | 1 + src/payment-methods/payment-methods.controller.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/orders/@/dtos/create-order.dto.ts b/src/orders/@/dtos/create-order.dto.ts index 0672127..18397c1 100644 --- a/src/orders/@/dtos/create-order.dto.ts +++ b/src/orders/@/dtos/create-order.dto.ts @@ -12,6 +12,7 @@ export class CreateOrderDto extends IntersectionType( "guestPhone", "guestsAmount", "delayedTo", + "paymentMethodId", ]), ), ) {} diff --git a/src/payment-methods/payment-methods.controller.ts b/src/payment-methods/payment-methods.controller.ts index a64559d..b944721 100644 --- a/src/payment-methods/payment-methods.controller.ts +++ b/src/payment-methods/payment-methods.controller.ts @@ -9,6 +9,7 @@ import { CreatePaymentMethodDto } from "src/payment-methods/dto/create-payment-m 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"], @@ -16,6 +17,10 @@ import { PaymentMethodsService } from "src/payment-methods/payment-methods.servi 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) @@ -32,6 +37,10 @@ export class PaymentMethodsController { }); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) @EnableAuditLog() @Post() @Serializable(PaymentMethodEntity) @@ -57,6 +66,10 @@ export class PaymentMethodsController { ); } + @RestaurantGuard({ + restaurantId: (req) => req.params.id, + allow: ["OWNER", "ADMIN"], + }) @EnableAuditLog() @Patch(":paymentMethodId") @Serializable(PaymentMethodEntity) From 2c890bc6e5e572e6dfddd7e986a0a78216a7c691 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 21 Feb 2025 18:47:29 +0200 Subject: [PATCH 117/180] fix: dish modifiers is removed flag --- .../dish-modifiers/dto/create-restaurant-dish-modifier.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1fd8ad8..8e5d85d 100644 --- a/src/restaurants/dish-modifiers/dto/create-restaurant-dish-modifier.dto.ts +++ b/src/restaurants/dish-modifiers/dto/create-restaurant-dish-modifier.dto.ts @@ -3,5 +3,5 @@ import { RestaurantDishModifierEntity } from "src/restaurants/dish-modifiers/ent export class CreateRestaurantDishModifierDto extends OmitType( RestaurantDishModifierEntity, - ["id", "createdAt", "updatedAt", "removedAt"] as const, + ["id", "createdAt", "updatedAt", "removedAt", "isRemoved"] as const, ) {} From 19ddc80a3b9d822f3dd40eacb5235aa6360432a8 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 24 Feb 2025 13:59:47 +0200 Subject: [PATCH 118/180] feat: endpoint for assigning order dish modifiers --- src/@base/drizzle/schema/dish-modifiers.ts | 4 +- src/i18n/messages/en/errors.json | 7 +- src/orders/@/dtos/put-order-dish-modifiers.ts | 14 ++++ src/orders/@/order-dishes.controller.ts | 19 +++++- src/orders/@/services/order-dishes.service.ts | 67 +++++++++++++++++++ 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 src/orders/@/dtos/put-order-dish-modifiers.ts diff --git a/src/@base/drizzle/schema/dish-modifiers.ts b/src/@base/drizzle/schema/dish-modifiers.ts index a44a362..7eb8992 100644 --- a/src/@base/drizzle/schema/dish-modifiers.ts +++ b/src/@base/drizzle/schema/dish-modifiers.ts @@ -34,8 +34,8 @@ export type IDishModifier = typeof dishModifiers.$inferSelect; export const dishModifiersToOrderDishes = pgTable( "dishModifiersToOrderDishes", { - dishModifierId: uuid("dishModifierId"), - orderDishId: uuid("orderDishId"), + dishModifierId: uuid("dishModifierId").notNull(), + orderDishId: uuid("orderDishId").notNull(), }, (t) => [primaryKey({ columns: [t.dishModifierId, t.orderDishId] })], ); diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index bea1923..734fc71 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -81,7 +81,12 @@ "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-force-not-cooking-dish": "You can't force ready not cooking dish", + "cant-update-ready-dish": "You can't update ready dish" + }, + "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" 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/@/order-dishes.controller.ts b/src/orders/@/order-dishes.controller.ts index d038b3f..5926570 100644 --- a/src/orders/@/order-dishes.controller.ts +++ b/src/orders/@/order-dishes.controller.ts @@ -2,7 +2,7 @@ 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 } from "@nestjs/common"; +import { Body, Delete, Param, Patch, Post, Put } from "@nestjs/common"; import { ApiBadRequestResponse, ApiNotFoundResponse, @@ -11,6 +11,7 @@ import { } 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 { 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 { OrderDishesService } from "src/orders/@/services/order-dishes.service"; @@ -116,4 +117,20 @@ export class OrderDishesController { 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/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 9f1cdc4..5418d54 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -3,11 +3,13 @@ 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 { dishModifiersToOrderDishes } from "@postgress-db/schema/dish-modifiers"; import { orderDishes } from "@postgress-db/schema/order-dishes"; 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 { OrderPricesService } from "src/orders/@/services/order-prices.service"; import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @@ -293,4 +295,69 @@ export class OrderDishesService { return updatedOrderDish; } + + 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 !== "pending" && orderDish.status !== "cooking") { + 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 + await tx.insert(dishModifiersToOrderDishes).values( + dishModifiers.map(({ id }) => ({ + dishModifierId: id, + orderDishId, + })), + ); + }); + + return orderDish; + } } From bd9ca06ae4fb4fb2c3452ee614d19c88d56e6c80 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 24 Feb 2025 14:40:40 +0200 Subject: [PATCH 119/180] feat: return order dishes modifiers for get request --- .../@/entities/order-dish-modifier.entity.ts | 19 +++++++ src/orders/@/entities/order-dish.entity.ts | 13 ++++- src/orders/@/services/orders.service.ts | 57 ++++++++++++++++++- src/orders/@queue/dto/crud-update.job.ts | 2 +- 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/orders/@/entities/order-dish-modifier.entity.ts 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..7e8bdec --- /dev/null +++ b/src/orders/@/entities/order-dish-modifier.entity.ts @@ -0,0 +1,19 @@ +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", + }) + id: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the order dish modifier", + }) + name: string; +} diff --git a/src/orders/@/entities/order-dish.entity.ts b/src/orders/@/entities/order-dish.entity.ts index b9e12de..181ccbf 100644 --- a/src/orders/@/entities/order-dish.entity.ts +++ b/src/orders/@/entities/order-dish.entity.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsDate, IsDecimal, @@ -14,7 +15,8 @@ import { OrderDishStatusEnum, ZodOrderDishStatusEnum, } from "@postgress-db/schema/order-dishes"; -import { Expose } from "class-transformer"; +import { Expose, Type } from "class-transformer"; +import { OrderDishModifierEntity } from "src/orders/@/entities/order-dish-modifier.entity"; export class OrderDishEntity implements IOrderDish { @IsUUID() @@ -77,6 +79,15 @@ export class OrderDishEntity implements IOrderDish { // status: typeof ZodOrderDishStatusEnum._type; status: OrderDishStatusEnum; + @Expose() + @IsArray() + @Type(() => OrderDishModifierEntity) + @ApiProperty({ + description: "Dish modifiers", + example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + }) + modifiers: OrderDishModifierEntity[]; + @IsInt() @Expose() @ApiProperty({ diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 199064d..2787ef3 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -11,6 +11,7 @@ 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 { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @@ -263,6 +264,40 @@ export class OrdersService { })); } + 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), @@ -272,7 +307,22 @@ export class OrdersService { name: true, }, }, - orderDishes: true, + orderDishes: { + with: { + dishModifiersToOrderDishes: { + with: { + dishModifier: { + columns: { + name: true, + }, + }, + }, + columns: { + dishModifierId: true, + }, + }, + }, + }, }, }); @@ -280,6 +330,9 @@ export class OrdersService { throw new NotFoundException("errors.orders.with-this-id-doesnt-exist"); } - return this.attachRestaurantsName([order])[0]; + 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 index 52941f7..429085c 100644 --- a/src/orders/@queue/dto/crud-update.job.ts +++ b/src/orders/@queue/dto/crud-update.job.ts @@ -11,7 +11,7 @@ export class OrderCrudUpdateJobDto { export class OrderDishCrudUpdateJobDto { orderDishId: string; - orderDish: OrderDishEntity; + orderDish: Omit; action: `${CrudAction}`; calledByWorkerId?: string; } From 71f562b50a946fd802b4f949c25bebb5688c5516 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 24 Feb 2025 14:43:19 +0200 Subject: [PATCH 120/180] fix: swagger entity for order dish --- src/orders/@/entities/order-dish-modifier.entity.ts | 2 ++ src/orders/@/entities/order-dish.entity.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/orders/@/entities/order-dish-modifier.entity.ts b/src/orders/@/entities/order-dish-modifier.entity.ts index 7e8bdec..fdfbe36 100644 --- a/src/orders/@/entities/order-dish-modifier.entity.ts +++ b/src/orders/@/entities/order-dish-modifier.entity.ts @@ -7,6 +7,7 @@ export class OrderDishModifierEntity { @Expose() @ApiProperty({ description: "Unique identifier of the order dish modifier", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", }) id: string; @@ -14,6 +15,7 @@ export class OrderDishModifierEntity { @Expose() @ApiProperty({ description: "Name of the order dish modifier", + example: "Extra Cheese", }) name: string; } diff --git a/src/orders/@/entities/order-dish.entity.ts b/src/orders/@/entities/order-dish.entity.ts index 181ccbf..2976273 100644 --- a/src/orders/@/entities/order-dish.entity.ts +++ b/src/orders/@/entities/order-dish.entity.ts @@ -84,7 +84,7 @@ export class OrderDishEntity implements IOrderDish { @Type(() => OrderDishModifierEntity) @ApiProperty({ description: "Dish modifiers", - example: ["d290f1ee-6c54-4b01-90e6-d701748f0851"], + type: [OrderDishModifierEntity], }) modifiers: OrderDishModifierEntity[]; From 0dfafd3ddf5da7c2b24be1116b4237f1f0642787 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 24 Feb 2025 15:41:36 +0200 Subject: [PATCH 121/180] fix: removing modifiers behaviour --- src/orders/@/services/order-dishes.service.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 5418d54..26b854c 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -350,12 +350,14 @@ export class OrderDishesService { .where(eq(dishModifiersToOrderDishes.orderDishId, orderDishId)); // Insert new dish modifiers for this order dish - await tx.insert(dishModifiersToOrderDishes).values( - dishModifiers.map(({ id }) => ({ - dishModifierId: id, - orderDishId, - })), - ); + if (payload.dishModifierIds.length > 0) { + await tx.insert(dishModifiersToOrderDishes).values( + dishModifiers.map(({ id }) => ({ + dishModifierId: id, + orderDishId, + })), + ); + } }); return orderDish; From 37aeb9fb5e2354ec8123762859491e0120f1d227 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 25 Feb 2025 18:18:37 +0200 Subject: [PATCH 122/180] feat: start of kitcheners orders api endpoint development --- .../drizzle/schema/restaurant-workshop.ts | 28 +-- src/@base/drizzle/schema/workers.ts | 2 +- .../entities/kitchener-order-dish.entity.ts | 11 ++ .../entities/kitchener-order.entity.ts | 25 +++ .../kitchener/kitchener-orders.controller.ts | 36 ++++ .../kitchener/kitchener-orders.service.ts | 163 ++++++++++++++++++ src/orders/orders.module.ts | 4 + 7 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 src/orders/kitchener/entities/kitchener-order-dish.entity.ts create mode 100644 src/orders/kitchener/entities/kitchener-order.entity.ts create mode 100644 src/orders/kitchener/kitchener-orders.controller.ts create mode 100644 src/orders/kitchener/kitchener-orders.service.ts diff --git a/src/@base/drizzle/schema/restaurant-workshop.ts b/src/@base/drizzle/schema/restaurant-workshop.ts index 5cd4e8e..789bdf0 100644 --- a/src/@base/drizzle/schema/restaurant-workshop.ts +++ b/src/@base/drizzle/schema/restaurant-workshop.ts @@ -28,20 +28,6 @@ export const restaurantWorkshops = pgTable("restaurantWorkshop", { updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); -export type IRestaurantWorkshop = typeof restaurantWorkshops.$inferSelect; - -export const workshopWorkers = pgTable("workshopWorkers", { - workerId: uuid("workerId") - .notNull() - .references(() => workers.id), - workshopId: uuid("workshopId") - .notNull() - .references(() => restaurantWorkshops.id), - createdAt: timestamp("createdAt").notNull().defaultNow(), -}); - -export type IWorkshopWorker = typeof workshopWorkers.$inferSelect; - export const restaurantWorkshopRelations = relations( restaurantWorkshops, ({ one, many }) => ({ @@ -54,6 +40,18 @@ export const restaurantWorkshopRelations = relations( }), ); +export type IRestaurantWorkshop = typeof restaurantWorkshops.$inferSelect; + +export const workshopWorkers = pgTable("workshopWorkers", { + workerId: uuid("workerId") + .notNull() + .references(() => workers.id), + workshopId: uuid("workshopId") + .notNull() + .references(() => restaurantWorkshops.id), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + export const workshopWorkerRelations = relations( workshopWorkers, ({ one }) => ({ @@ -67,3 +65,5 @@ export const workshopWorkerRelations = relations( }), }), ); + +export type IWorkshopWorker = typeof workshopWorkers.$inferSelect; diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 645a0d7..104acc2 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -86,7 +86,7 @@ export const workersToRestaurantsRelations = relations( export const workerRelations = relations(workers, ({ many }) => ({ workersToRestaurants: many(workersToRestaurants), sessions: many(sessions), - workshops: many(workshopWorkers), + workshopWorkers: many(workshopWorkers), deliveries: many(orderDeliveries), ownedRestaurants: many(restaurants), })); 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..7db07bb --- /dev/null +++ b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts @@ -0,0 +1,11 @@ +import { PickType } from "@nestjs/swagger"; +import { OrderDishEntity } from "src/orders/@/entities/order-dish.entity"; + +export class KitchenerOrderDishEntity extends PickType(OrderDishEntity, [ + "id", + "status", + "name", + "quantity", + "quantityReturned", + "isAdditional", +]) {} 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..7fcebdd --- /dev/null +++ b/src/orders/kitchener/entities/kitchener-order.entity.ts @@ -0,0 +1,25 @@ +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", + "number", + "tableNumber", + "from", + "type", + "note", + "guestsAmount", + "createdAt", + "updatedAt", + "delayedTo", +]) { + @Expose() + @ApiProperty({ + description: "Order dishes", + type: [KitchenerOrderDishEntity], + }) + @Type(() => KitchenerOrderDishEntity) + orderDishes: KitchenerOrderDishEntity[]; +} diff --git a/src/orders/kitchener/kitchener-orders.controller.ts b/src/orders/kitchener/kitchener-orders.controller.ts new file mode 100644 index 0000000..b4c0aad --- /dev/null +++ b/src/orders/kitchener/kitchener-orders.controller.ts @@ -0,0 +1,36 @@ +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 } 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 { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.service"; + +@Controller("kitchener/orders", { + tags: ["kitchener"], +}) +export class KitchenerOrdersController { + constructor( + private readonly kitchenerOrdersService: KitchenerOrdersService, + ) {} + + @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; + } +} diff --git a/src/orders/kitchener/kitchener-orders.service.ts b/src/orders/kitchener/kitchener-orders.service.ts new file mode 100644 index 0000000..a5a96d6 --- /dev/null +++ b/src/orders/kitchener/kitchener-orders.service.ts @@ -0,0 +1,163 @@ +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { Schema } from "@postgress-db/drizzle.module"; +import { dishesToWorkshops } from "@postgress-db/schema/dishes"; +import { orderDishes } from "@postgress-db/schema/order-dishes"; +import { IWorker } from "@postgress-db/schema/workers"; +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 getWorkerWorkshopIds( + worker: Pick, + ): Promise { + // 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, + }, + }, + }, + columns: { + workshopId: true, + }, + }); + + return workerWorkshops + .filter((ww) => !!ww.workshop && ww.workshop.isEnabled) + .map((ww) => ww.workshopId); + } + + async findMany(opts: { + worker: RequestWorker; + }): Promise { + const { worker } = opts; + const workerId = worker.id; + const restaurantIds = worker.workersToRestaurants.map( + (wtr) => wtr.restaurantId, + ); + + const workerWorkshopIds = await this.getWorkerWorkshopIds({ + id: workerId, + role: worker.role, + }); + + 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, exists, inArray }) => + 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"), + ), + // Only dishes with workshopId in workerWorkshopIds + Array.isArray(workerWorkshopIds) + ? exists( + this.pg + .select({ id: dishesToWorkshops.workshopId }) + .from(dishesToWorkshops) + .where( + and( + eq(dishesToWorkshops.dishId, orderDishes.dishId), + inArray( + dishesToWorkshops.workshopId, + workerWorkshopIds, + ), + ), + ), + ) + : undefined, + ), + // Select + columns: { + id: true, + status: true, + name: true, + quantity: true, + quantityReturned: true, + isAdditional: true, + }, + }, + }, + columns: { + id: true, + number: true, + tableNumber: true, + from: true, + type: true, + note: true, + guestsAmount: true, + createdAt: true, + updatedAt: true, + delayedTo: true, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + limit: 100, + }); + + return fetchedOrders; + } +} diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index 687b8f3..9accbc0 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -11,12 +11,15 @@ 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 { KitchenerOrdersController } from "src/orders/kitchener/kitchener-orders.controller"; +import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.service"; @Module({ imports: [DrizzleModule, GuestsModule, OrdersQueueModule], providers: [ OrdersService, DispatcherOrdersService, + KitchenerOrdersService, OrderDishesService, OrderPricesService, OrderActionsService, @@ -25,6 +28,7 @@ import { DispatcherOrdersService } from "src/orders/dispatcher/dispatcher-orders OrdersController, OrderDishesController, DispatcherOrdersController, + KitchenerOrdersController, OrderActionsController, ], exports: [OrdersService, OrderDishesService], From 9c4f191012432d2eaee3d255b6e0b4ebcba25242 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 26 Feb 2025 08:56:58 +0200 Subject: [PATCH 123/180] feat: fetching workshops for order dishes --- .../entities/kitchener-order-dish.entity.ts | 40 +++++++- .../entities/kitchener-order.entity.ts | 1 + .../kitchener/kitchener-orders.service.ts | 99 ++++++++++++++----- 3 files changed, 114 insertions(+), 26 deletions(-) diff --git a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts index 7db07bb..ff18a08 100644 --- a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts +++ b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts @@ -1,6 +1,34 @@ -import { PickType } from "@nestjs/swagger"; +import { IsBoolean, IsString, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; 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 PickType(OrderDishEntity, [ "id", "status", @@ -8,4 +36,12 @@ export class KitchenerOrderDishEntity extends PickType(OrderDishEntity, [ "quantity", "quantityReturned", "isAdditional", -]) {} +]) { + @Expose() + @ApiProperty({ + description: "Workshops", + type: [KitchenerOrderDishWorkshopEntity], + }) + @Type(() => KitchenerOrderDishWorkshopEntity) + workshops: KitchenerOrderDishWorkshopEntity[]; +} diff --git a/src/orders/kitchener/entities/kitchener-order.entity.ts b/src/orders/kitchener/entities/kitchener-order.entity.ts index 7fcebdd..f847d99 100644 --- a/src/orders/kitchener/entities/kitchener-order.entity.ts +++ b/src/orders/kitchener/entities/kitchener-order.entity.ts @@ -5,6 +5,7 @@ import { KitchenerOrderDishEntity } from "src/orders/kitchener/entities/kitchene export class KitchenerOrderEntity extends PickType(OrderEntity, [ "id", + "status", "number", "tableNumber", "from", diff --git a/src/orders/kitchener/kitchener-orders.service.ts b/src/orders/kitchener/kitchener-orders.service.ts index a5a96d6..bd9ec67 100644 --- a/src/orders/kitchener/kitchener-orders.service.ts +++ b/src/orders/kitchener/kitchener-orders.service.ts @@ -1,8 +1,9 @@ import { RequestWorker } from "@core/interfaces/request"; import { Inject, Injectable } from "@nestjs/common"; import { Schema } from "@postgress-db/drizzle.module"; -import { dishesToWorkshops } from "@postgress-db/schema/dishes"; 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 { SQL } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; @@ -21,9 +22,9 @@ export class KitchenerOrdersService { * @param workerId - The id of the worker * @returns An array of workshop ids */ - private async getWorkerWorkshopIds( + private async getWorkerWorkshops( worker: Pick, - ): Promise { + ): 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; @@ -36,6 +37,7 @@ export class KitchenerOrdersService { workshop: { columns: { isEnabled: true, + name: true, }, }, }, @@ -46,7 +48,10 @@ export class KitchenerOrdersService { return workerWorkshops .filter((ww) => !!ww.workshop && ww.workshop.isEnabled) - .map((ww) => ww.workshopId); + .map((ww) => ({ + id: ww.workshopId, + name: ww.workshop.name, + })); } async findMany(opts: { @@ -58,11 +63,15 @@ export class KitchenerOrdersService { (wtr) => wtr.restaurantId, ); - const workerWorkshopIds = await this.getWorkerWorkshopIds({ + 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[] = [ @@ -102,7 +111,7 @@ export class KitchenerOrdersService { with: { orderDishes: { // Filter - where: (orderDishes, { eq, and, gt, or, exists, inArray }) => + where: (orderDishes, { eq, and, gt, or }) => and( // Only not removed eq(orderDishes.isRemoved, false), @@ -113,23 +122,6 @@ export class KitchenerOrdersService { eq(orderDishes.status, "cooking"), eq(orderDishes.status, "ready"), ), - // Only dishes with workshopId in workerWorkshopIds - Array.isArray(workerWorkshopIds) - ? exists( - this.pg - .select({ id: dishesToWorkshops.workshopId }) - .from(dishesToWorkshops) - .where( - and( - eq(dishesToWorkshops.dishId, orderDishes.dishId), - inArray( - dishesToWorkshops.workshopId, - workerWorkshopIds, - ), - ), - ), - ) - : undefined, ), // Select columns: { @@ -140,10 +132,50 @@ export class KitchenerOrdersService { quantityReturned: true, isAdditional: true, }, + with: { + 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: {}, + }, + }, }, }, columns: { id: true, + status: true, number: true, tableNumber: true, from: true, @@ -158,6 +190,25 @@ export class KitchenerOrdersService { limit: 100, }); - return fetchedOrders; + return fetchedOrders.map( + ({ orderDishes, ...order }) => + ({ + ...order, + orderDishes: orderDishes.map(({ dish, ...orderDish }) => ({ + ...orderDish, + 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, + ); } } From e4cd67646cd0086de46c70dfe94b70a0a5f3c71d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 26 Feb 2025 09:48:08 +0200 Subject: [PATCH 124/180] feat: load modifiers for kitchener order dishes --- .../entities/kitchener-order-dish.entity.ts | 1 + .../kitchener/kitchener-orders.service.ts | 48 +++++++++++++------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts index ff18a08..7370a12 100644 --- a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts +++ b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts @@ -36,6 +36,7 @@ export class KitchenerOrderDishEntity extends PickType(OrderDishEntity, [ "quantity", "quantityReturned", "isAdditional", + "modifiers", ]) { @Expose() @ApiProperty({ diff --git a/src/orders/kitchener/kitchener-orders.service.ts b/src/orders/kitchener/kitchener-orders.service.ts index bd9ec67..f6bfbd8 100644 --- a/src/orders/kitchener/kitchener-orders.service.ts +++ b/src/orders/kitchener/kitchener-orders.service.ts @@ -133,6 +133,18 @@ export class KitchenerOrdersService { isAdditional: true, }, with: { + dishModifiersToOrderDishes: { + columns: { + dishModifierId: true, + }, + with: { + dishModifier: { + columns: { + name: true, + }, + }, + }, + }, dish: { with: { dishesToWorkshops: { @@ -194,20 +206,28 @@ export class KitchenerOrdersService { ({ orderDishes, ...order }) => ({ ...order, - orderDishes: orderDishes.map(({ dish, ...orderDish }) => ({ - ...orderDish, - 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], - ), - })), + orderDishes: orderDishes.map( + ({ dish, dishModifiersToOrderDishes, ...orderDish }) => ({ + ...orderDish, + 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, ); } From 656fc9633895ba0ac4bd0004dfd858ec244e878e Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 26 Feb 2025 10:47:43 +0200 Subject: [PATCH 125/180] feat: timestamps for orders and order dishes --- src/@base/drizzle/schema/order-dishes.ts | 2 ++ src/@base/drizzle/schema/orders.ts | 1 + src/orders/@/entities/order-dish.entity.ts | 18 ++++++++++++++++++ src/orders/@/entities/order.entity.ts | 9 +++++++++ src/orders/@/services/order-actions.service.ts | 11 +++++++++++ .../entities/kitchener-order-dish.entity.ts | 2 ++ .../entities/kitchener-order.entity.ts | 1 + .../kitchener/kitchener-orders.service.ts | 3 +++ 8 files changed, 47 insertions(+) diff --git a/src/@base/drizzle/schema/order-dishes.ts b/src/@base/drizzle/schema/order-dishes.ts index 0e75227..b2b5269 100644 --- a/src/@base/drizzle/schema/order-dishes.ts +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -68,6 +68,8 @@ export const orderDishes = pgTable( // Timestamps // createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), + cookingAt: timestamp("cookingAt"), + readyAt: timestamp("readyAt"), removedAt: timestamp("removedAt"), }, (table) => [index("orderDishes_orderId_idx").on(table.orderId)], diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts index 65572a2..8697c57 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -110,6 +110,7 @@ export const orders = pgTable( // Default timestamps createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), + cookingAt: timestamp("cookingAt"), completedAt: timestamp("completedAt"), removedAt: timestamp("removedAt"), delayedTo: timestamp("delayedTo"), diff --git a/src/orders/@/entities/order-dish.entity.ts b/src/orders/@/entities/order-dish.entity.ts index 2976273..3a1f67f 100644 --- a/src/orders/@/entities/order-dish.entity.ts +++ b/src/orders/@/entities/order-dish.entity.ts @@ -184,6 +184,24 @@ export class OrderDishEntity implements IOrderDish { }) 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() diff --git a/src/orders/@/entities/order.entity.ts b/src/orders/@/entities/order.entity.ts index 9d435b5..715d58b 100644 --- a/src/orders/@/entities/order.entity.ts +++ b/src/orders/@/entities/order.entity.ts @@ -247,6 +247,15 @@ export class OrderEntity implements IOrder { }) updatedAt: Date; + @Expose() + @IsDate() + @IsOptional() + @ApiPropertyOptional({ + description: "Date when order was cooking", + example: null, + }) + cookingAt: Date | null; + @IsDate() @IsOptional() @Expose() diff --git a/src/orders/@/services/order-actions.service.ts b/src/orders/@/services/order-actions.service.ts index f010c69..a0244b5 100644 --- a/src/orders/@/services/order-actions.service.ts +++ b/src/orders/@/services/order-actions.service.ts @@ -72,6 +72,13 @@ export class OrderActionsService { 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( @@ -102,8 +109,10 @@ export class OrderActionsService { .set({ status: "cooking", isAdditional, + cookingAt: new Date(), }) .where( + // Change status of pending dishes to cooking inArray( orderDishes.id, pendingDishes.map((d) => d.id), @@ -115,6 +124,8 @@ export class OrderActionsService { .update(orders) .set({ status: "cooking", + cookingAt: + order && order.cookingAt ? new Date(order.cookingAt) : new Date(), }) .where(eq(orders.id, orderId)); }); diff --git a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts index 7370a12..22d882e 100644 --- a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts +++ b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts @@ -37,6 +37,8 @@ export class KitchenerOrderDishEntity extends PickType(OrderDishEntity, [ "quantityReturned", "isAdditional", "modifiers", + "cookingAt", + "readyAt", ]) { @Expose() @ApiProperty({ diff --git a/src/orders/kitchener/entities/kitchener-order.entity.ts b/src/orders/kitchener/entities/kitchener-order.entity.ts index f847d99..5763750 100644 --- a/src/orders/kitchener/entities/kitchener-order.entity.ts +++ b/src/orders/kitchener/entities/kitchener-order.entity.ts @@ -14,6 +14,7 @@ export class KitchenerOrderEntity extends PickType(OrderEntity, [ "guestsAmount", "createdAt", "updatedAt", + "cookingAt", "delayedTo", ]) { @Expose() diff --git a/src/orders/kitchener/kitchener-orders.service.ts b/src/orders/kitchener/kitchener-orders.service.ts index f6bfbd8..cc5afc3 100644 --- a/src/orders/kitchener/kitchener-orders.service.ts +++ b/src/orders/kitchener/kitchener-orders.service.ts @@ -131,6 +131,8 @@ export class KitchenerOrdersService { quantity: true, quantityReturned: true, isAdditional: true, + cookingAt: true, + readyAt: true, }, with: { dishModifiersToOrderDishes: { @@ -197,6 +199,7 @@ export class KitchenerOrdersService { createdAt: true, updatedAt: true, delayedTo: true, + cookingAt: true, }, orderBy: (orders, { desc }) => [desc(orders.createdAt)], limit: 100, From 5f9e0e70c0ef8e948217e2f4f81f821a4ab581c3 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 26 Feb 2025 14:15:50 +0200 Subject: [PATCH 126/180] feat: add cooking time and is ready on time flag --- src/orders/@/services/order-dishes.service.ts | 5 ++- .../entities/kitchener-order-dish.entity.ts | 36 ++++++++++++------- .../kitchener/kitchener-orders.service.ts | 32 ++++++++++++++++- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 26b854c..e790382 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -279,7 +279,10 @@ export class OrderDishesService { const updatedOrderDish = await this.pg.transaction(async (tx) => { const [updatedOrderDish] = await tx .update(orderDishes) - .set({ status: "ready" }) + .set({ + status: "ready", + readyAt: new Date(), + }) .where(eq(orderDishes.id, orderDishId)) .returning(); diff --git a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts index 22d882e..1a19a14 100644 --- a/src/orders/kitchener/entities/kitchener-order-dish.entity.ts +++ b/src/orders/kitchener/entities/kitchener-order-dish.entity.ts @@ -1,6 +1,7 @@ import { IsBoolean, IsString, IsUUID } from "@i18n-class-validator"; -import { ApiProperty, PickType } from "@nestjs/swagger"; +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 { @@ -29,17 +30,20 @@ export class KitchenerOrderDishWorkshopEntity { isMyWorkshop: boolean; } -export class KitchenerOrderDishEntity extends PickType(OrderDishEntity, [ - "id", - "status", - "name", - "quantity", - "quantityReturned", - "isAdditional", - "modifiers", - "cookingAt", - "readyAt", -]) { +export class KitchenerOrderDishEntity extends IntersectionType( + PickType(OrderDishEntity, [ + "id", + "status", + "name", + "quantity", + "quantityReturned", + "isAdditional", + "modifiers", + "cookingAt", + "readyAt", + ]), + PickType(DishEntity, ["cookingTimeInMin"]), +) { @Expose() @ApiProperty({ description: "Workshops", @@ -47,4 +51,12 @@ export class KitchenerOrderDishEntity extends PickType(OrderDishEntity, [ }) @Type(() => KitchenerOrderDishWorkshopEntity) workshops: KitchenerOrderDishWorkshopEntity[]; + + @Expose() + @IsBoolean() + @ApiProperty({ + description: "Is ready in time", + example: false, + }) + isReadyOnTime: boolean; } diff --git a/src/orders/kitchener/kitchener-orders.service.ts b/src/orders/kitchener/kitchener-orders.service.ts index cc5afc3..9970b6d 100644 --- a/src/orders/kitchener/kitchener-orders.service.ts +++ b/src/orders/kitchener/kitchener-orders.service.ts @@ -5,6 +5,7 @@ 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"; @@ -54,6 +55,27 @@ export class KitchenerOrdersService { })); } + 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 { @@ -182,7 +204,9 @@ export class KitchenerOrdersService { }, }, }, - columns: {}, + columns: { + cookingTimeInMin: true, + }, }, }, }, @@ -212,6 +236,12 @@ export class KitchenerOrdersService { 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, From 29c6da0c99ad734e2a38bee3b86d893bd16bc32a Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 26 Feb 2025 15:29:46 +0200 Subject: [PATCH 127/180] refactor: service for kitchener actions --- src/orders/@/order-dishes.controller.ts | 4 +- src/orders/@/services/order-dishes.service.ts | 36 ----------- .../kitchener-order-actions.service.ts | 60 +++++++++++++++++++ .../kitchener/kitchener-orders.controller.ts | 24 +++++++- src/orders/orders.module.ts | 2 + 5 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 src/orders/kitchener/kitchener-order-actions.service.ts diff --git a/src/orders/@/order-dishes.controller.ts b/src/orders/@/order-dishes.controller.ts index 5926570..60aa33c 100644 --- a/src/orders/@/order-dishes.controller.ts +++ b/src/orders/@/order-dishes.controller.ts @@ -16,6 +16,7 @@ import { UpdateOrderDishDto } from "src/orders/@/dtos/update-order-dish.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"; +import { KitchenerOrderActionsService } from "src/orders/kitchener/kitchener-order-actions.service"; @Controller("orders/:id/dishes", { tags: ["orders"], @@ -24,6 +25,7 @@ export class OrderDishesController { constructor( private readonly ordersService: OrdersService, private readonly orderDishesService: OrderDishesService, + private readonly kitchenerOrderActionsService: KitchenerOrderActionsService, ) {} @EnableAuditLog() @@ -111,7 +113,7 @@ export class OrderDishesController { @Param("orderDishId") orderDishId: string, @Worker() worker: RequestWorker, ) { - await this.orderDishesService.forceReady(orderDishId, { + await this.kitchenerOrderActionsService.markDishAsReady(orderDishId, { worker, }); diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index e790382..f0f93d5 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -1,6 +1,5 @@ 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 { dishModifiersToOrderDishes } from "@postgress-db/schema/dish-modifiers"; @@ -264,41 +263,6 @@ export class OrderDishesService { return removedOrderDish; } - public async forceReady( - orderDishId: string, - opts?: { worker?: RequestWorker }, - ) { - const orderDish = await this.getOrderDish(orderDishId); - - if (orderDish.status !== "cooking") { - throw new BadRequestException( - "errors.order-dishes.cant-force-not-cooking-dish", - ); - } - - const updatedOrderDish = await this.pg.transaction(async (tx) => { - const [updatedOrderDish] = await tx - .update(orderDishes) - .set({ - status: "ready", - readyAt: new Date(), - }) - .where(eq(orderDishes.id, orderDishId)) - .returning(); - - return updatedOrderDish; - }); - - await this.ordersProducer.dishCrudUpdate({ - action: "UPDATE", - orderDishId: orderDish.id, - orderDish: updatedOrderDish, - calledByWorkerId: opts?.worker?.id, - }); - - return updatedOrderDish; - } - public async updateDishModifiers( orderDishId: string, payload: PutOrderDishModifiersDto, 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..6b69ee9 --- /dev/null +++ b/src/orders/kitchener/kitchener-order-actions.service.ts @@ -0,0 +1,60 @@ +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 { orderDishes } from "@postgress-db/schema/order-dishes"; +import { eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +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, + ) {} + + public async markDishAsReady( + orderDishId: string, + opts?: { worker?: RequestWorker }, + ) { + 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"); + } + + if (orderDish.status !== "cooking") { + throw new BadRequestException( + "errors.order-dishes.cant-force-not-cooking-dish", + ); + } + + const updatedOrderDish = await this.pg.transaction(async (tx) => { + const [updatedOrderDish] = await tx + .update(orderDishes) + .set({ + status: "ready", + readyAt: new Date(), + }) + .where(eq(orderDishes.id, orderDishId)) + .returning(); + + return updatedOrderDish; + }); + + 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 index b4c0aad..a003d56 100644 --- a/src/orders/kitchener/kitchener-orders.controller.ts +++ b/src/orders/kitchener/kitchener-orders.controller.ts @@ -2,10 +2,11 @@ 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 } from "@nestjs/common"; +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", { @@ -14,6 +15,7 @@ import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.se export class KitchenerOrdersController { constructor( private readonly kitchenerOrdersService: KitchenerOrdersService, + private readonly kitchenerOrderActionsService: KitchenerOrderActionsService, ) {} @EnableAuditLog({ onlyErrors: true }) @@ -33,4 +35,24 @@ export class KitchenerOrdersController { 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, + ) { + await this.kitchenerOrderActionsService.markDishAsReady(orderDishId, { + worker, + }); + + return true; + } } diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index 9accbc0..e8d58fc 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -11,6 +11,7 @@ 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"; @@ -23,6 +24,7 @@ import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.se OrderDishesService, OrderPricesService, OrderActionsService, + KitchenerOrderActionsService, ], controllers: [ OrdersController, From dad1d5c5caa0cf348f4a8ef649c95419ba55988a Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 27 Feb 2025 19:09:02 +0200 Subject: [PATCH 128/180] feat: discounts drizzle model --- src/@base/drizzle/schema/discounts.ts | 82 +++++++++++++++++++ src/@base/drizzle/schema/restaurants.ts | 2 + src/orders/@/order-dishes.controller.ts | 1 + .../kitchener/kitchener-orders.controller.ts | 1 + 4 files changed, 86 insertions(+) create mode 100644 src/@base/drizzle/schema/discounts.ts diff --git a/src/@base/drizzle/schema/discounts.ts b/src/@base/drizzle/schema/discounts.ts new file mode 100644 index 0000000..8c9e0de --- /dev/null +++ b/src/@base/drizzle/schema/discounts.ts @@ -0,0 +1,82 @@ +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, + decimal, + 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 // + value: decimal("value", { precision: 10, scale: 2 }).default("0"), + + // Basic conditions // + orderFroms: orderFromEnum("orderFroms").array().notNull(), + orderTypes: orderTypeEnum("orderTypes").array().notNull(), + daysOfWeek: dayOfWeekEnum("daysOfWeek").array().notNull(), + + // Advanced conditions // + promocode: text("promocode"), + onlyFirstOrder: boolean("onlyFirstOrder").notNull().default(false), + limitedByTime: boolean("limitedByTime").notNull().default(false), + + // Boolean flags // + isEnabled: boolean("isEnabled").notNull().default(true), + + // Valid time // + startHour: integer("startHour"), + endHour: integer("endHour"), + activeFrom: timestamp("activeFrom").notNull(), + activeTo: timestamp("activeTo").notNull(), + + // Timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export const discountRelations = relations(discounts, ({ many }) => ({ + discountsToRestaurants: many(discountsToRestaurants), +})); + +export const discountsToRestaurants = pgTable( + "discountsToRestaurants", + { + discountId: uuid("discountId").notNull(), + restaurantId: uuid("restaurantId").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/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 764548e..93e4807 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -1,3 +1,4 @@ +import { discountsToRestaurants } from "@postgress-db/schema/discounts"; import { dishModifiers } from "@postgress-db/schema/dish-modifiers"; import { dishesToRestaurants } from "@postgress-db/schema/dishes"; import { orders } from "@postgress-db/schema/orders"; @@ -87,6 +88,7 @@ export const restaurantRelations = relations(restaurants, ({ one, many }) => ({ references: [workers.id], }), dishModifiers: many(dishModifiers), + discountsToRestaurants: many(discountsToRestaurants), })); export const restaurantHourRelations = relations( diff --git a/src/orders/@/order-dishes.controller.ts b/src/orders/@/order-dishes.controller.ts index 60aa33c..ac94f7e 100644 --- a/src/orders/@/order-dishes.controller.ts +++ b/src/orders/@/order-dishes.controller.ts @@ -113,6 +113,7 @@ export class OrderDishesController { @Param("orderDishId") orderDishId: string, @Worker() worker: RequestWorker, ) { + // TODO: restrict access to admins await this.kitchenerOrderActionsService.markDishAsReady(orderDishId, { worker, }); diff --git a/src/orders/kitchener/kitchener-orders.controller.ts b/src/orders/kitchener/kitchener-orders.controller.ts index a003d56..3de1fda 100644 --- a/src/orders/kitchener/kitchener-orders.controller.ts +++ b/src/orders/kitchener/kitchener-orders.controller.ts @@ -49,6 +49,7 @@ export class KitchenerOrdersController { @Param("orderDishId") orderDishId: string, @Worker() worker: RequestWorker, ) { + // TODO: restrict access to kitchener workers await this.kitchenerOrderActionsService.markDishAsReady(orderDishId, { worker, }); From 888596df521032caa42fabe5f765d896a00ca938 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 28 Feb 2025 14:55:53 +0200 Subject: [PATCH 129/180] feat: discounts controller with service (find many) --- src/@base/drizzle/drizzle.module.ts | 2 + src/@base/drizzle/schema/discounts.ts | 10 +- src/@base/drizzle/schema/general.ts | 2 + src/app.module.ts | 2 + src/discounts/discounts.controller.ts | 34 +++ src/discounts/discounts.module.ts | 13 ++ src/discounts/dto/create-discount.dto.ts | 31 +++ src/discounts/dto/update-discount.dto.ts | 5 + src/discounts/entities/discount.entity.ts | 201 ++++++++++++++++++ src/discounts/services/discounts.service.ts | 68 ++++++ .../services/order-discounts.service.ts | 23 ++ 11 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 src/discounts/discounts.controller.ts create mode 100644 src/discounts/discounts.module.ts create mode 100644 src/discounts/dto/create-discount.dto.ts create mode 100644 src/discounts/dto/update-discount.dto.ts create mode 100644 src/discounts/entities/discount.entity.ts create mode 100644 src/discounts/services/discounts.service.ts create mode 100644 src/discounts/services/order-discounts.service.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index af3f4e6..3cbb6ac 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -6,6 +6,7 @@ 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"; @@ -38,6 +39,7 @@ export const schema = { ...orders, ...paymentMethods, ...dishModifiers, + ...discounts, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/discounts.ts b/src/@base/drizzle/schema/discounts.ts index 8c9e0de..037043f 100644 --- a/src/@base/drizzle/schema/discounts.ts +++ b/src/@base/drizzle/schema/discounts.ts @@ -4,7 +4,6 @@ import { restaurants } from "@postgress-db/schema/restaurants"; import { relations } from "drizzle-orm"; import { boolean, - decimal, integer, pgTable, primaryKey, @@ -24,7 +23,7 @@ export const discounts = pgTable("discounts", { description: text("description").default(""), // Info // - value: decimal("value", { precision: 10, scale: 2 }).default("0"), + percent: integer("percent").notNull().default(0), // Basic conditions // orderFroms: orderFromEnum("orderFroms").array().notNull(), @@ -33,8 +32,9 @@ export const discounts = pgTable("discounts", { // Advanced conditions // promocode: text("promocode"), - onlyFirstOrder: boolean("onlyFirstOrder").notNull().default(false), - limitedByTime: boolean("limitedByTime").notNull().default(false), + applyByPromocode: boolean("applyByPromocode").notNull().default(false), + applyForFirstOrder: boolean("applyForFirstOrder").notNull().default(false), + applyByDefault: boolean("applyByDefault").notNull().default(false), // Boolean flags // isEnabled: boolean("isEnabled").notNull().default(true), @@ -50,6 +50,8 @@ export const discounts = pgTable("discounts", { updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); +export type IDiscount = typeof discounts.$inferSelect; + export const discountRelations = relations(discounts, ({ many }) => ({ discountsToRestaurants: many(discountsToRestaurants), })); diff --git a/src/@base/drizzle/schema/general.ts b/src/@base/drizzle/schema/general.ts index 2251dd0..c8d03e7 100644 --- a/src/@base/drizzle/schema/general.ts +++ b/src/@base/drizzle/schema/general.ts @@ -11,6 +11,8 @@ export const dayOfWeekEnum = pgEnum("day_of_week", [ "sunday", ]); +export const ZodDayOfWeekEnum = z.enum(dayOfWeekEnum.enumValues); + export const currencyEnum = pgEnum("currency", ["EUR", "USD", "RUB"]); export const ZodCurrency = z.enum(currencyEnum.enumValues); diff --git a/src/app.module.ts b/src/app.module.ts index e61f885..011ede0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ 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 { FilesModule } from "src/files/files.module"; @@ -98,6 +99,7 @@ import { WorkersModule } from "./workers/workers.module"; }), SocketModule, PaymentMethodsModule, + DiscountsModule, ], providers: [ { diff --git a/src/discounts/discounts.controller.ts b/src/discounts/discounts.controller.ts new file mode 100644 index 0000000..70a77ce --- /dev/null +++ b/src/discounts/discounts.controller.ts @@ -0,0 +1,34 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Worker } from "@core/decorators/worker.decorator"; +import { RequestWorker } from "@core/interfaces/request"; +import { Get } from "@nestjs/common"; +import { + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { DiscountEntity } from "src/discounts/entities/discount.entity"; + +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 }); + } +} 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..d3f06b3 --- /dev/null +++ b/src/discounts/dto/create-discount.dto.ts @@ -0,0 +1,31 @@ +import { IsArray, IsUUID } from "@i18n-class-validator"; +import { ApiProperty, PickType } from "@nestjs/swagger"; + +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", +]) { + @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..a42971f --- /dev/null +++ b/src/discounts/entities/discount.entity.ts @@ -0,0 +1,201 @@ +import { + IsArray, + IsBoolean, + IsDate, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUUID, +} 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() + @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() + @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; + + @IsDate() + @Expose() + @ApiProperty({ + description: "Start date of the discount validity", + example: new Date("2024-01-01T00:00:00.000Z"), + }) + activeFrom: Date; + + @IsDate() + @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..709d962 --- /dev/null +++ b/src/discounts/services/discounts.service.ts @@ -0,0 +1,68 @@ +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { discountsToRestaurants } from "@postgress-db/schema/discounts"; +import { and, exists, inArray, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +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, + }, + }, + }, + }, + }, + }); + + return fetchedDiscounts.map(({ discountsToRestaurants, ...discount }) => ({ + ...discount, + restaurants: discountsToRestaurants.map(({ restaurant }) => ({ + restaurantId: restaurant.id, + restaurantName: restaurant.name, + })), + })); + } +} diff --git a/src/discounts/services/order-discounts.service.ts b/src/discounts/services/order-discounts.service.ts new file mode 100644 index 0000000..ae6a76d --- /dev/null +++ b/src/discounts/services/order-discounts.service.ts @@ -0,0 +1,23 @@ +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, + }, + }); + } +} From 2ff3403db86625886bc428560a29ceeee0a841c8 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 28 Feb 2025 15:19:58 +0200 Subject: [PATCH 130/180] feat: create discount endpoint --- src/discounts/discounts.controller.ts | 24 +++++- src/discounts/dto/create-discount.dto.ts | 2 + src/discounts/entities/discount.entity.ts | 5 +- src/discounts/services/discounts.service.ts | 95 ++++++++++++++++++++- src/i18n/messages/en/errors.json | 4 + 5 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/discounts/discounts.controller.ts b/src/discounts/discounts.controller.ts index 70a77ce..11d7246 100644 --- a/src/discounts/discounts.controller.ts +++ b/src/discounts/discounts.controller.ts @@ -1,14 +1,17 @@ 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 } from "@nestjs/common"; +import { Body, Get, 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 { DiscountsService } from "./services/discounts.service"; @@ -31,4 +34,23 @@ export class DiscountsController { async findAll(@Worker() worker: RequestWorker) { return this.discountsService.findMany({ 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, + }); + } } diff --git a/src/discounts/dto/create-discount.dto.ts b/src/discounts/dto/create-discount.dto.ts index d3f06b3..7cc9d32 100644 --- a/src/discounts/dto/create-discount.dto.ts +++ b/src/discounts/dto/create-discount.dto.ts @@ -1,5 +1,6 @@ import { IsArray, IsUUID } from "@i18n-class-validator"; import { ApiProperty, PickType } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; import { DiscountEntity } from "../entities/discount.entity"; @@ -20,6 +21,7 @@ export class CreateDiscountDto extends PickType(DiscountEntity, [ "activeFrom", "activeTo", ]) { + @Expose() @IsArray() @IsUUID(undefined, { each: true }) @ApiProperty({ diff --git a/src/discounts/entities/discount.entity.ts b/src/discounts/entities/discount.entity.ts index a42971f..3581489 100644 --- a/src/discounts/entities/discount.entity.ts +++ b/src/discounts/entities/discount.entity.ts @@ -3,6 +3,7 @@ import { IsBoolean, IsDate, IsEnum, + IsISO8601, IsNumber, IsOptional, IsString, @@ -158,7 +159,7 @@ export class DiscountEntity implements IDiscount { }) endHour: number | null; - @IsDate() + @IsISO8601() @Expose() @ApiProperty({ description: "Start date of the discount validity", @@ -166,7 +167,7 @@ export class DiscountEntity implements IDiscount { }) activeFrom: Date; - @IsDate() + @IsISO8601() @Expose() @ApiProperty({ description: "End date of the discount validity", diff --git a/src/discounts/services/discounts.service.ts b/src/discounts/services/discounts.service.ts index 709d962..64304ed 100644 --- a/src/discounts/services/discounts.service.ts +++ b/src/discounts/services/discounts.service.ts @@ -1,10 +1,16 @@ +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 { discountsToRestaurants } from "@postgress-db/schema/discounts"; -import { and, exists, inArray, SQL } from "drizzle-orm"; +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() @@ -65,4 +71,89 @@ export class DiscountsService { })), })); } + + public async findOne(id: string) { + 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", + ); + } + + // 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", + ); + } + } + } + + 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); + } } diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 734fc71..9cfa029 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -95,5 +95,9 @@ "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" } } From 3739cd090522fc1180ba5288cde2af5752425724 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 28 Feb 2025 15:23:55 +0200 Subject: [PATCH 131/180] feat: update method for discounts with patch --- src/discounts/discounts.controller.ts | 23 ++++++++- src/discounts/services/discounts.service.ts | 57 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/discounts/discounts.controller.ts b/src/discounts/discounts.controller.ts index 11d7246..2515074 100644 --- a/src/discounts/discounts.controller.ts +++ b/src/discounts/discounts.controller.ts @@ -2,7 +2,7 @@ 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, Post } from "@nestjs/common"; +import { Body, Get, Post, Patch, Param } from "@nestjs/common"; import { ApiCreatedResponse, ApiForbiddenResponse, @@ -13,6 +13,7 @@ import { 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"; @@ -53,4 +54,24 @@ export class DiscountsController { 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/services/discounts.service.ts b/src/discounts/services/discounts.service.ts index 64304ed..eb05a82 100644 --- a/src/discounts/services/discounts.service.ts +++ b/src/discounts/services/discounts.service.ts @@ -156,4 +156,61 @@ export class DiscountsService { return await this.findOne(discount.id); } + + public async update( + id: string, + payload: UpdateDiscountDto, + options: { worker: RequestWorker }, + ) { + const { worker } = options; + + const existingDiscount = await this.findOne(id); + 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); + } } From f0899807fe34568c8e0de2be19f092f70e52d5b7 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 3 Mar 2025 16:33:16 +0200 Subject: [PATCH 132/180] feat: get discount by id api method --- src/discounts/discounts.controller.ts | 13 +++++++++++-- src/discounts/services/discounts.service.ts | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/discounts/discounts.controller.ts b/src/discounts/discounts.controller.ts index 2515074..9c5da80 100644 --- a/src/discounts/discounts.controller.ts +++ b/src/discounts/discounts.controller.ts @@ -2,7 +2,7 @@ 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, Post, Patch, Param } from "@nestjs/common"; +import { Body, Get, Param, Patch, Post } from "@nestjs/common"; import { ApiCreatedResponse, ApiForbiddenResponse, @@ -13,8 +13,8 @@ import { 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 { UpdateDiscountDto } from "./dto/update-discount.dto"; import { DiscountsService } from "./services/discounts.service"; @Controller("discounts") @@ -36,6 +36,15 @@ export class DiscountsController { 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) diff --git a/src/discounts/services/discounts.service.ts b/src/discounts/services/discounts.service.ts index eb05a82..4fbc508 100644 --- a/src/discounts/services/discounts.service.ts +++ b/src/discounts/services/discounts.service.ts @@ -72,7 +72,9 @@ export class DiscountsService { })); } - public async findOne(id: string) { + public async findOne(id: string, options: { worker?: RequestWorker }) { + const { worker } = options; + const discount = await this.pg.query.discounts.findFirst({ where: eq(discounts.id, id), with: { @@ -104,6 +106,9 @@ export class DiscountsService { if (!payload.restaurantIds || payload.restaurantIds.length === 0) { throw new BadRequestException( "errors.discounts.you-should-provide-at-least-one-restaurant-id", + { + property: "restaurantIds", + }, ); } @@ -119,6 +124,9 @@ export class DiscountsService { if (payload.restaurantIds.some((id) => !restaurantIdsSet.has(id))) { throw new BadRequestException( "errors.discounts.you-provided-restaurant-id-that-you-dont-own", + { + property: "restaurantIds", + }, ); } } @@ -154,7 +162,7 @@ export class DiscountsService { return discount; }); - return await this.findOne(discount.id); + return await this.findOne(discount.id, { worker }); } public async update( @@ -164,7 +172,7 @@ export class DiscountsService { ) { const { worker } = options; - const existingDiscount = await this.findOne(id); + const existingDiscount = await this.findOne(id, { worker }); if (!existingDiscount) { throw new BadRequestException( "errors.discounts.discount-with-provided-id-not-found", @@ -211,6 +219,6 @@ export class DiscountsService { return discount; }); - return await this.findOne(updatedDiscount.id); + return await this.findOne(updatedDiscount.id, { worker }); } } From 27bcfb21fedfefa6766da59a0371f6d02766ab7e Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 3 Mar 2025 16:55:18 +0200 Subject: [PATCH 133/180] feat: order by createdAt for discounts by default --- src/discounts/services/discounts.service.ts | 3 +++ src/discounts/services/order-discounts.service.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/discounts/services/discounts.service.ts b/src/discounts/services/discounts.service.ts index 4fbc508..d53af2a 100644 --- a/src/discounts/services/discounts.service.ts +++ b/src/discounts/services/discounts.service.ts @@ -61,6 +61,7 @@ export class DiscountsService { }, }, }, + orderBy: (discounts, { desc }) => [desc(discounts.createdAt)], }); return fetchedDiscounts.map(({ discountsToRestaurants, ...discount }) => ({ @@ -75,6 +76,8 @@ export class DiscountsService { 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: { diff --git a/src/discounts/services/order-discounts.service.ts b/src/discounts/services/order-discounts.service.ts index ae6a76d..986decd 100644 --- a/src/discounts/services/order-discounts.service.ts +++ b/src/discounts/services/order-discounts.service.ts @@ -19,5 +19,7 @@ export class OrderDiscountsService { restaurantId: true, }, }); + + order; } } From 2f607776a885078ead737feb04c0f3f3a3f15ce1 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 3 Mar 2025 16:57:35 +0200 Subject: [PATCH 134/180] feat: some basic validations for discount entity --- src/discounts/entities/discount.entity.ts | 6 ++++++ src/i18n/validators/index.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/discounts/entities/discount.entity.ts b/src/discounts/entities/discount.entity.ts index 3581489..29b67f1 100644 --- a/src/discounts/entities/discount.entity.ts +++ b/src/discounts/entities/discount.entity.ts @@ -8,6 +8,9 @@ import { IsOptional, IsString, IsUUID, + Max, + Min, + MinLength, } from "@i18n-class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { IDiscount } from "@postgress-db/schema/discounts"; @@ -46,6 +49,7 @@ export class DiscountEntity implements IDiscount { id: string; @IsString() + @MinLength(2) @Expose() @ApiProperty({ description: "Name of the discount", @@ -63,6 +67,8 @@ export class DiscountEntity implements IDiscount { description: string | null; @IsNumber() + @Min(0) + @Max(100) @Expose() @ApiProperty({ description: "Percent of the discount", diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts index f029237..e8efc68 100644 --- a/src/i18n/validators/index.ts +++ b/src/i18n/validators/index.ts @@ -16,6 +16,7 @@ import { IsUUID as _IsUUID, Min as _Min, MinLength as _MinLength, + Max as _Max, IsNumberOptions, ValidationOptions, } from "class-validator"; @@ -144,3 +145,6 @@ export const IsDecimal = ( 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))); From 00c42647ab914d1e64656e0603e259c3031fba1b Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 3 Mar 2025 17:51:02 +0200 Subject: [PATCH 135/180] feat: payment methods assignment for order and dto validations --- src/i18n/messages/en/errors.json | 6 ++- src/orders/@/services/orders.service.ts | 51 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 9cfa029..0af46ad 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -68,7 +68,11 @@ "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" + "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", diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index 2787ef3..e9df518 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -114,6 +114,53 @@ export class OrdersService { 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( @@ -131,6 +178,7 @@ export class OrdersService { delayedTo, restaurantId, tableNumber, + paymentMethodId, } = dto; const number = await this.generateOrderNumber(); @@ -145,6 +193,7 @@ export class OrdersService { from: "internal", status: "pending", currency: "RUB", + paymentMethodId, ...(delayedTo ? { delayedTo: new Date(delayedTo) } : {}), guestsAmount, note, @@ -200,6 +249,7 @@ export class OrdersService { guestPhone, guestsAmount, type, + paymentMethodId, } = dto; let guestName = @@ -225,6 +275,7 @@ export class OrdersService { ...(guestPhone ? { guestPhone } : {}), ...(guestsAmount ? { guestsAmount } : {}), ...(type ? { type } : {}), + ...(paymentMethodId ? { paymentMethodId } : {}), }) .where(eq(orders.id, id)) .returning({ id: orders.id }); From c30aced051b6d40be54c64cc44087b94485fd440 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 4 Mar 2025 19:00:55 +0200 Subject: [PATCH 136/180] feat: dishes menu drizzle schema --- src/@base/drizzle/drizzle.module.ts | 2 ++ src/@base/drizzle/schema/dishes-menu.ts | 28 +++++++++++++++++++++++++ src/@base/drizzle/schema/dishes.ts | 10 ++++++++- src/@base/drizzle/schema/workers.ts | 2 ++ src/dishes/@/entities/dish.entity.ts | 12 ++++++++++- 5 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 src/@base/drizzle/schema/dishes-menu.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index 3cbb6ac..a95dbdc 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -10,6 +10,7 @@ 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 dishesMenu from "./schema/dishes-menu"; import * as files from "./schema/files"; import * as general from "./schema/general"; import * as guests from "./schema/guests"; @@ -40,6 +41,7 @@ export const schema = { ...paymentMethods, ...dishModifiers, ...discounts, + ...dishesMenu, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/dishes-menu.ts b/src/@base/drizzle/schema/dishes-menu.ts new file mode 100644 index 0000000..1606583 --- /dev/null +++ b/src/@base/drizzle/schema/dishes-menu.ts @@ -0,0 +1,28 @@ +import { dishes } from "@postgress-db/schema/dishes"; +import { workers } from "@postgress-db/schema/workers"; +import { relations } from "drizzle-orm"; +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const dishesMenu = pgTable("dishesMenu", { + id: uuid("id").defaultRandom().primaryKey(), + + // Name of the menu with dishes // + name: text("name").notNull().default(""), + + // Owner of the menu // + ownerId: uuid("ownerId").notNull(), + + // Default timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export type IDishesMenu = typeof dishesMenu.$inferSelect; + +export const dishesMenuRelations = relations(dishesMenu, ({ one, many }) => ({ + dishes: many(dishes), + owner: one(workers, { + fields: [dishesMenu.ownerId], + references: [workers.id], + }), +})); diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts index a184e51..8e12835 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -1,3 +1,4 @@ +import { dishesMenu } from "@postgress-db/schema/dishes-menu"; import { currencyEnum } from "@postgress-db/schema/general"; import { dishesToCategories, @@ -32,6 +33,9 @@ export type WeightMeasureEnum = typeof ZodWeightMeasureEnum._type; export const dishes = pgTable("dishes", { id: uuid("id").defaultRandom().primaryKey(), + // Menu // + menuId: uuid("menuId"), + // Name of the dish // name: text("name").notNull().default(""), @@ -129,10 +133,14 @@ export const dishesToWorkshopsRelations = relations( }), ); -export const dishRelations = relations(dishes, ({ many }) => ({ +export const dishRelations = relations(dishes, ({ one, many }) => ({ dishesToCategories: many(dishesToCategories), dishesToImages: many(dishesToImages), dishesToWorkshops: many(dishesToWorkshops), dishesToRestaurants: many(dishesToRestaurants), orderDishes: many(orderDishes), + menu: one(dishesMenu, { + fields: [dishes.menuId], + references: [dishesMenu.id], + }), })); diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 104acc2..035cee8 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -1,3 +1,4 @@ +import { dishesMenu } from "@postgress-db/schema/dishes-menu"; import { orderDeliveries } from "@postgress-db/schema/order-deliveries"; import { restaurants } from "@postgress-db/schema/restaurants"; import { relations } from "drizzle-orm"; @@ -89,6 +90,7 @@ export const workerRelations = relations(workers, ({ many }) => ({ workshopWorkers: many(workshopWorkers), deliveries: many(orderDeliveries), ownedRestaurants: many(restaurants), + ownedDishesMenus: many(dishesMenu), })); export type IWorker = typeof workers.$inferSelect; diff --git a/src/dishes/@/entities/dish.entity.ts b/src/dishes/@/entities/dish.entity.ts index debb78c..1c27e73 100644 --- a/src/dishes/@/entities/dish.entity.ts +++ b/src/dishes/@/entities/dish.entity.ts @@ -3,11 +3,12 @@ import { IsEnum, IsISO8601, IsNumber, + IsOptional, IsString, IsUUID, ValidateNested, } from "@i18n-class-validator"; -import { ApiProperty } from "@nestjs/swagger"; +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"; @@ -21,6 +22,15 @@ export class DishEntity implements IDish { }) id: string; + @Expose() + @IsOptional() + @IsUUID() + @ApiPropertyOptional({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + menuId: string | null; + @Expose() @IsString() @ApiProperty({ From 27d9b83d150a718549f52e103ffa0d718a9748b1 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 5 Mar 2025 19:32:29 +0200 Subject: [PATCH 137/180] feat: dish menus progress --- src/@base/drizzle/schema/dishes-menu.ts | 2 +- src/app.module.ts | 2 + src/dish-menus/dish-menus.controller.ts | 21 ++++++++++ src/dish-menus/dish-menus.module.ts | 11 +++++ src/dish-menus/dish-menus.service.ts | 28 +++++++++++++ src/dish-menus/dto/create-dish-menu.dto.ts | 7 ++++ src/dish-menus/dto/update-dish-menu.dto.ts | 4 ++ src/dish-menus/entity/dish-menu.entity.ts | 48 ++++++++++++++++++++++ 8 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/dish-menus/dish-menus.controller.ts create mode 100644 src/dish-menus/dish-menus.module.ts create mode 100644 src/dish-menus/dish-menus.service.ts create mode 100644 src/dish-menus/dto/create-dish-menu.dto.ts create mode 100644 src/dish-menus/dto/update-dish-menu.dto.ts create mode 100644 src/dish-menus/entity/dish-menu.entity.ts diff --git a/src/@base/drizzle/schema/dishes-menu.ts b/src/@base/drizzle/schema/dishes-menu.ts index 1606583..8b2cb01 100644 --- a/src/@base/drizzle/schema/dishes-menu.ts +++ b/src/@base/drizzle/schema/dishes-menu.ts @@ -17,7 +17,7 @@ export const dishesMenu = pgTable("dishesMenu", { updatedAt: timestamp("updatedAt").notNull().defaultNow(), }); -export type IDishesMenu = typeof dishesMenu.$inferSelect; +export type IDishMenu = typeof dishesMenu.$inferSelect; export const dishesMenuRelations = relations(dishesMenu, ({ one, many }) => ({ dishes: many(dishes), diff --git a/src/app.module.ts b/src/app.module.ts index 011ede0..ffddafe 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,6 +26,7 @@ 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 { DishMenusModule } from "src/dish-menus/dish-menus.module"; import { DishesModule } from "src/dishes/dishes.module"; import { FilesModule } from "src/files/files.module"; import { GuestsModule } from "src/guests/guests.module"; @@ -79,6 +80,7 @@ import { WorkersModule } from "./workers/workers.module"; RestaurantsModule, AddressesModule, GuestsModule, + DishMenusModule, DishesModule, DishCategoriesModule, S3Module, diff --git a/src/dish-menus/dish-menus.controller.ts b/src/dish-menus/dish-menus.controller.ts new file mode 100644 index 0000000..a41ba39 --- /dev/null +++ b/src/dish-menus/dish-menus.controller.ts @@ -0,0 +1,21 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { Serializable } from "@core/decorators/serializable.decorator"; +import { Get } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { DishMenuEntity } from "src/dish-menus/entity/dish-menu.entity"; + +@Controller("dish-menus") +export class DishMenusController { + @EnableAuditLog({ onlyErrors: true }) + @Get() + @Serializable(DishMenuEntity) + @ApiOperation({ + summary: "Gets all dish menus", + }) + @ApiOkResponse({ + description: "Dish menus have been successfully fetched", + type: [DishMenuEntity], + }) + async findAll() {} +} diff --git a/src/dish-menus/dish-menus.module.ts b/src/dish-menus/dish-menus.module.ts new file mode 100644 index 0000000..3dd77fe --- /dev/null +++ b/src/dish-menus/dish-menus.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { DishMenusController } from "src/dish-menus/dish-menus.controller"; + +@Module({ + imports: [DrizzleModule], + controllers: [DishMenusController], + providers: [], + exports: [], +}) +export class DishMenusModule {} diff --git a/src/dish-menus/dish-menus.service.ts b/src/dish-menus/dish-menus.service.ts new file mode 100644 index 0000000..172c3f4 --- /dev/null +++ b/src/dish-menus/dish-menus.service.ts @@ -0,0 +1,28 @@ +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { dishesMenu } from "@postgress-db/schema/dishes-menu"; +import { and, eq, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; + +@Injectable() +export class DishMenusService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + public async findMany(options: { worker: RequestWorker }) { + const { worker } = options; + + const conditions: SQL[] = []; + + if (worker.role === "OWNER") { + conditions.push(eq(dishesMenu.ownerId, worker.id)); + } + + return await this.pg.query.dishesMenu.findMany({ + ...(conditions.length > 0 && { where: and(...conditions) }), + }); + } +} diff --git a/src/dish-menus/dto/create-dish-menu.dto.ts b/src/dish-menus/dto/create-dish-menu.dto.ts new file mode 100644 index 0000000..cb9659d --- /dev/null +++ b/src/dish-menus/dto/create-dish-menu.dto.ts @@ -0,0 +1,7 @@ +import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; +import { DishMenuEntity } from "src/dish-menus/entity/dish-menu.entity"; + +export class CreateDishMenuDto extends IntersectionType( + PickType(DishMenuEntity, ["name"]), + PartialType(PickType(DishMenuEntity, ["ownerId"])), +) {} diff --git a/src/dish-menus/dto/update-dish-menu.dto.ts b/src/dish-menus/dto/update-dish-menu.dto.ts new file mode 100644 index 0000000..4eab4eb --- /dev/null +++ b/src/dish-menus/dto/update-dish-menu.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateDishDto } from "src/dishes/@/dtos/create-dish.dto"; + +export class UpdateDishMenuDto extends PartialType(CreateDishDto) {} diff --git a/src/dish-menus/entity/dish-menu.entity.ts b/src/dish-menus/entity/dish-menu.entity.ts new file mode 100644 index 0000000..ac457d0 --- /dev/null +++ b/src/dish-menus/entity/dish-menu.entity.ts @@ -0,0 +1,48 @@ +import { IsISO8601, IsString, IsUUID } from "@i18n-class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IDishMenu } from "@postgress-db/schema/dishes-menu"; +import { Expose } from "class-transformer"; + +export class DishMenuEntity implements IDishMenu { + @IsUUID() + @Expose() + @ApiProperty({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + id: string; + + @IsString() + @Expose() + @ApiProperty({ + description: "Name of the menu", + example: "Lunch Menu", + }) + name: string; + + @IsUUID() + @Expose() + @ApiProperty({ + description: "Owner identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + ownerId: string; + + @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; +} From afa38aa4b2c793ccb2375f8f0879b4499c346d13 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 15:24:53 +0200 Subject: [PATCH 138/180] feat: dishes menus assign a restaurants --- src/@base/drizzle/drizzle.module.ts | 4 +- src/@base/drizzle/schema/dishes-menu.ts | 28 --------- src/@base/drizzle/schema/dishes-menus.ts | 63 +++++++++++++++++++ src/@base/drizzle/schema/dishes.ts | 8 +-- src/@base/drizzle/schema/order-deliveries.ts | 2 +- src/@base/drizzle/schema/order-dishes.ts | 2 +- src/@base/drizzle/schema/orders.ts | 6 +- src/@base/drizzle/schema/payment-methods.ts | 4 +- src/@base/drizzle/schema/restaurants.ts | 2 + src/@base/drizzle/schema/workers.ts | 6 +- src/app.module.ts | 4 +- src/dish-menus/dish-menus.controller.ts | 21 ------- src/dish-menus/dish-menus.module.ts | 11 ---- src/dish-menus/dish-menus.service.ts | 28 --------- src/dish-menus/dto/create-dish-menu.dto.ts | 7 --- src/dish-menus/dto/update-dish-menu.dto.ts | 4 -- src/dishes-menus/dishes-menus.controller.ts | 30 +++++++++ src/dishes-menus/dishes-menus.module.ts | 12 ++++ src/dishes-menus/dishes-menus.service.ts | 60 ++++++++++++++++++ .../dto/create-dishes-menu.dto.ts | 21 +++++++ .../dto/update-dishes-menu.dto.ts | 4 ++ .../entity/dishes-menu.entity.ts} | 25 ++++++-- 22 files changed, 230 insertions(+), 122 deletions(-) delete mode 100644 src/@base/drizzle/schema/dishes-menu.ts create mode 100644 src/@base/drizzle/schema/dishes-menus.ts delete mode 100644 src/dish-menus/dish-menus.controller.ts delete mode 100644 src/dish-menus/dish-menus.module.ts delete mode 100644 src/dish-menus/dish-menus.service.ts delete mode 100644 src/dish-menus/dto/create-dish-menu.dto.ts delete mode 100644 src/dish-menus/dto/update-dish-menu.dto.ts create mode 100644 src/dishes-menus/dishes-menus.controller.ts create mode 100644 src/dishes-menus/dishes-menus.module.ts create mode 100644 src/dishes-menus/dishes-menus.service.ts create mode 100644 src/dishes-menus/dto/create-dishes-menu.dto.ts create mode 100644 src/dishes-menus/dto/update-dishes-menu.dto.ts rename src/{dish-menus/entity/dish-menu.entity.ts => dishes-menus/entity/dishes-menu.entity.ts} (53%) diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index a95dbdc..ebf07cf 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -10,7 +10,7 @@ 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 dishesMenu from "./schema/dishes-menu"; +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"; @@ -41,7 +41,7 @@ export const schema = { ...paymentMethods, ...dishModifiers, ...discounts, - ...dishesMenu, + ...dishesMenus, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/dishes-menu.ts b/src/@base/drizzle/schema/dishes-menu.ts deleted file mode 100644 index 8b2cb01..0000000 --- a/src/@base/drizzle/schema/dishes-menu.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { dishes } from "@postgress-db/schema/dishes"; -import { workers } from "@postgress-db/schema/workers"; -import { relations } from "drizzle-orm"; -import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; - -export const dishesMenu = pgTable("dishesMenu", { - id: uuid("id").defaultRandom().primaryKey(), - - // Name of the menu with dishes // - name: text("name").notNull().default(""), - - // Owner of the menu // - ownerId: uuid("ownerId").notNull(), - - // Default timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), -}); - -export type IDishMenu = typeof dishesMenu.$inferSelect; - -export const dishesMenuRelations = relations(dishesMenu, ({ one, many }) => ({ - dishes: many(dishes), - owner: one(workers, { - fields: [dishesMenu.ownerId], - references: [workers.id], - }), -})); diff --git a/src/@base/drizzle/schema/dishes-menus.ts b/src/@base/drizzle/schema/dishes-menus.ts new file mode 100644 index 0000000..3872cdc --- /dev/null +++ b/src/@base/drizzle/schema/dishes-menus.ts @@ -0,0 +1,63 @@ +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 { + pgTable, + primaryKey, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +export const dishesMenus = pgTable("dishesMenu", { + id: uuid("id").defaultRandom().primaryKey(), + + // Name of the menu with dishes // + name: text("name").notNull().default(""), + + // Owner of the menu // + ownerId: uuid("ownerId").notNull(), + + // Default timestamps // + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export type IDishesMenu = typeof dishesMenus.$inferSelect; + +export const dishesMenusToRestaurants = pgTable( + "dishesMenusToRestaurants", + { + restaurantId: uuid("restaurantId").notNull(), + dishesMenuId: uuid("dishesMenuId").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), + 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 index 8e12835..5de80a5 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -1,4 +1,4 @@ -import { dishesMenu } from "@postgress-db/schema/dishes-menu"; +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; import { currencyEnum } from "@postgress-db/schema/general"; import { dishesToCategories, @@ -21,7 +21,7 @@ import { } from "drizzle-orm/pg-core"; import { z } from "zod"; -export const weightMeasureEnum = pgEnum("weightMeasureEnum", [ +export const weightMeasureEnum = pgEnum("weight_measure_enum", [ "grams", "milliliters", ]); @@ -139,8 +139,8 @@ export const dishRelations = relations(dishes, ({ one, many }) => ({ dishesToWorkshops: many(dishesToWorkshops), dishesToRestaurants: many(dishesToRestaurants), orderDishes: many(orderDishes), - menu: one(dishesMenu, { + menu: one(dishesMenus, { fields: [dishes.menuId], - references: [dishesMenu.id], + references: [dishesMenus.id], }), })); diff --git a/src/@base/drizzle/schema/order-deliveries.ts b/src/@base/drizzle/schema/order-deliveries.ts index f3d0f45..960ff27 100644 --- a/src/@base/drizzle/schema/order-deliveries.ts +++ b/src/@base/drizzle/schema/order-deliveries.ts @@ -12,7 +12,7 @@ import { } from "drizzle-orm/pg-core"; import { z } from "zod"; -export const orderDeliveryStatusEnum = pgEnum("orderDeliveryStatusEnum", [ +export const orderDeliveryStatusEnum = pgEnum("order_delivery_status_enum", [ "pending", "dispatched", "delivered", diff --git a/src/@base/drizzle/schema/order-dishes.ts b/src/@base/drizzle/schema/order-dishes.ts index b2b5269..bc7ff4d 100644 --- a/src/@base/drizzle/schema/order-dishes.ts +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -15,7 +15,7 @@ import { } from "drizzle-orm/pg-core"; import { z } from "zod"; -export const orderDishStatusEnum = pgEnum("orderDishStatusEnum", [ +export const orderDishStatusEnum = pgEnum("order_dish_status_enum", [ "pending", "cooking", "ready", diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts index 8697c57..483e9f0 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -18,7 +18,7 @@ import { } from "drizzle-orm/pg-core"; import { z } from "zod"; -export const orderFromEnum = pgEnum("orderFromEnum", [ +export const orderFromEnum = pgEnum("order_from_enum", [ "app", "website", "internal", @@ -28,7 +28,7 @@ export const ZodOrderFromEnum = z.enum(orderFromEnum.enumValues); export type OrderFromEnum = typeof ZodOrderFromEnum._type; -export const orderStatusEnum = pgEnum("orderStatusEnum", [ +export const orderStatusEnum = pgEnum("order_status_enum", [ "pending", "cooking", "ready", @@ -42,7 +42,7 @@ export const ZodOrderStatusEnum = z.enum(orderStatusEnum.enumValues); export type OrderStatusEnum = typeof ZodOrderStatusEnum._type; -export const orderTypeEnum = pgEnum("orderTypeEnum", [ +export const orderTypeEnum = pgEnum("order_type_enum", [ "hall", "banquet", "takeaway", diff --git a/src/@base/drizzle/schema/payment-methods.ts b/src/@base/drizzle/schema/payment-methods.ts index f44f1ee..81b26b5 100644 --- a/src/@base/drizzle/schema/payment-methods.ts +++ b/src/@base/drizzle/schema/payment-methods.ts @@ -10,12 +10,12 @@ import { uuid, } from "drizzle-orm/pg-core"; -export const paymentMethodTypeEnum = pgEnum("paymentMethodType", [ +export const paymentMethodTypeEnum = pgEnum("payment_method_type", [ "YOO_KASSA", "CUSTOM", ]); -export const paymentMethodIconEnum = pgEnum("paymentMethodIcon", [ +export const paymentMethodIconEnum = pgEnum("payment_method_icon", [ "YOO_KASSA", "CASH", "CARD", diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 93e4807..bc18fc9 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -1,6 +1,7 @@ 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"; @@ -89,6 +90,7 @@ export const restaurantRelations = relations(restaurants, ({ one, many }) => ({ }), dishModifiers: many(dishModifiers), discountsToRestaurants: many(discountsToRestaurants), + dishesMenusToRestaurants: many(dishesMenusToRestaurants), })); export const restaurantHourRelations = relations( diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 035cee8..7be5197 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -1,4 +1,4 @@ -import { dishesMenu } from "@postgress-db/schema/dishes-menu"; +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; import { orderDeliveries } from "@postgress-db/schema/order-deliveries"; import { restaurants } from "@postgress-db/schema/restaurants"; import { relations } from "drizzle-orm"; @@ -16,7 +16,7 @@ import { z } from "zod"; import { workshopWorkers } from "./restaurant-workshop"; import { sessions } from "./sessions"; -export const workerRoleEnum = pgEnum("workerRoleEnum", [ +export const workerRoleEnum = pgEnum("worker_role_enum", [ "SYSTEM_ADMIN" as const, "CHIEF_ADMIN", "OWNER", @@ -90,7 +90,7 @@ export const workerRelations = relations(workers, ({ many }) => ({ workshopWorkers: many(workshopWorkers), deliveries: many(orderDeliveries), ownedRestaurants: many(restaurants), - ownedDishesMenus: many(dishesMenu), + ownedDishesMenus: many(dishesMenus), })); export type IWorker = typeof workers.$inferSelect; diff --git a/src/app.module.ts b/src/app.module.ts index ffddafe..c8170c5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,8 +26,8 @@ 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 { DishMenusModule } from "src/dish-menus/dish-menus.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"; @@ -80,7 +80,7 @@ import { WorkersModule } from "./workers/workers.module"; RestaurantsModule, AddressesModule, GuestsModule, - DishMenusModule, + DishesMenusModule, DishesModule, DishCategoriesModule, S3Module, diff --git a/src/dish-menus/dish-menus.controller.ts b/src/dish-menus/dish-menus.controller.ts deleted file mode 100644 index a41ba39..0000000 --- a/src/dish-menus/dish-menus.controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Controller } from "@core/decorators/controller.decorator"; -import { Serializable } from "@core/decorators/serializable.decorator"; -import { Get } from "@nestjs/common"; -import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; -import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; -import { DishMenuEntity } from "src/dish-menus/entity/dish-menu.entity"; - -@Controller("dish-menus") -export class DishMenusController { - @EnableAuditLog({ onlyErrors: true }) - @Get() - @Serializable(DishMenuEntity) - @ApiOperation({ - summary: "Gets all dish menus", - }) - @ApiOkResponse({ - description: "Dish menus have been successfully fetched", - type: [DishMenuEntity], - }) - async findAll() {} -} diff --git a/src/dish-menus/dish-menus.module.ts b/src/dish-menus/dish-menus.module.ts deleted file mode 100644 index 3dd77fe..0000000 --- a/src/dish-menus/dish-menus.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from "@nestjs/common"; -import { DrizzleModule } from "@postgress-db/drizzle.module"; -import { DishMenusController } from "src/dish-menus/dish-menus.controller"; - -@Module({ - imports: [DrizzleModule], - controllers: [DishMenusController], - providers: [], - exports: [], -}) -export class DishMenusModule {} diff --git a/src/dish-menus/dish-menus.service.ts b/src/dish-menus/dish-menus.service.ts deleted file mode 100644 index 172c3f4..0000000 --- a/src/dish-menus/dish-menus.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { RequestWorker } from "@core/interfaces/request"; -import { Inject, Injectable } from "@nestjs/common"; -import { schema } from "@postgress-db/drizzle.module"; -import { dishesMenu } from "@postgress-db/schema/dishes-menu"; -import { and, eq, SQL } from "drizzle-orm"; -import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { PG_CONNECTION } from "src/constants"; - -@Injectable() -export class DishMenusService { - constructor( - @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, - ) {} - - public async findMany(options: { worker: RequestWorker }) { - const { worker } = options; - - const conditions: SQL[] = []; - - if (worker.role === "OWNER") { - conditions.push(eq(dishesMenu.ownerId, worker.id)); - } - - return await this.pg.query.dishesMenu.findMany({ - ...(conditions.length > 0 && { where: and(...conditions) }), - }); - } -} diff --git a/src/dish-menus/dto/create-dish-menu.dto.ts b/src/dish-menus/dto/create-dish-menu.dto.ts deleted file mode 100644 index cb9659d..0000000 --- a/src/dish-menus/dto/create-dish-menu.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; -import { DishMenuEntity } from "src/dish-menus/entity/dish-menu.entity"; - -export class CreateDishMenuDto extends IntersectionType( - PickType(DishMenuEntity, ["name"]), - PartialType(PickType(DishMenuEntity, ["ownerId"])), -) {} diff --git a/src/dish-menus/dto/update-dish-menu.dto.ts b/src/dish-menus/dto/update-dish-menu.dto.ts deleted file mode 100644 index 4eab4eb..0000000 --- a/src/dish-menus/dto/update-dish-menu.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from "@nestjs/swagger"; -import { CreateDishDto } from "src/dishes/@/dtos/create-dish.dto"; - -export class UpdateDishMenuDto extends PartialType(CreateDishDto) {} diff --git a/src/dishes-menus/dishes-menus.controller.ts b/src/dishes-menus/dishes-menus.controller.ts new file mode 100644 index 0000000..a6dbcff --- /dev/null +++ b/src/dishes-menus/dishes-menus.controller.ts @@ -0,0 +1,30 @@ +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 } 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 { 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, + }); + } +} diff --git a/src/dishes-menus/dishes-menus.module.ts b/src/dishes-menus/dishes-menus.module.ts new file mode 100644 index 0000000..77f8bcf --- /dev/null +++ b/src/dishes-menus/dishes-menus.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { DishesMenusController } from "src/dishes-menus/dishes-menus.controller"; +import { DishesMenusService } from "src/dishes-menus/dishes-menus.service"; + +@Module({ + imports: [DrizzleModule], + controllers: [DishesMenusController], + providers: [DishesMenusService], + exports: [DishesMenusService], +}) +export class DishesMenusModule {} diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts new file mode 100644 index 0000000..9748f04 --- /dev/null +++ b/src/dishes-menus/dishes-menus.service.ts @@ -0,0 +1,60 @@ +import { RequestWorker } from "@core/interfaces/request"; +import { Inject, Injectable } from "@nestjs/common"; +import { schema } from "@postgress-db/drizzle.module"; +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; +import { and, eq, inArray, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { DishesMenuEntity } from "src/dishes-menus/entity/dishes-menu.entity"; + +@Injectable() +export class DishesMenusService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + public async findMany(options: { + worker: RequestWorker; + }): Promise { + const { worker } = options; + + const conditions: SQL[] = []; + + 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, + }, + }, + }, + }, + }, + }); + + return fetchedMenus.map(({ dishesMenusToRestaurants, ...dishesMenu }) => ({ + ...dishesMenu, + restaurants: dishesMenusToRestaurants.map(({ restaurant }) => restaurant), + })); + } +} 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..0459472 --- /dev/null +++ b/src/dishes-menus/dto/create-dishes-menu.dto.ts @@ -0,0 +1,21 @@ +import { IsArray, IsUUID } from "@i18n-class-validator"; +import { + ApiProperty, + IntersectionType, + PartialType, + PickType, +} from "@nestjs/swagger"; +import { DishesMenuEntity } from "src/dishes-menus/entity/dishes-menu.entity"; + +export class CreateDishesMenuDto extends IntersectionType( + PickType(DishesMenuEntity, ["name"]), + PartialType(PickType(DishesMenuEntity, ["ownerId"])), +) { + @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/dish-menus/entity/dish-menu.entity.ts b/src/dishes-menus/entity/dishes-menu.entity.ts similarity index 53% rename from src/dish-menus/entity/dish-menu.entity.ts rename to src/dishes-menus/entity/dishes-menu.entity.ts index ac457d0..c4dcef1 100644 --- a/src/dish-menus/entity/dish-menu.entity.ts +++ b/src/dishes-menus/entity/dishes-menu.entity.ts @@ -1,9 +1,15 @@ -import { IsISO8601, IsString, IsUUID } from "@i18n-class-validator"; -import { ApiProperty } from "@nestjs/swagger"; -import { IDishMenu } from "@postgress-db/schema/dishes-menu"; -import { Expose } from "class-transformer"; +import { IsArray, IsISO8601, IsString, IsUUID } 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"; -export class DishMenuEntity implements IDishMenu { +export class DishesMenuRestaurantEntity extends PickType(RestaurantEntity, [ + "id", + "name", +]) {} + +export class DishesMenuEntity implements IDishesMenu { @IsUUID() @Expose() @ApiProperty({ @@ -20,6 +26,15 @@ export class DishMenuEntity implements IDishMenu { }) name: string; + @Expose() + @IsArray() + @Type(() => DishesMenuRestaurantEntity) + @ApiProperty({ + description: "Restaurants that have this menu", + type: [DishesMenuRestaurantEntity], + }) + restaurants: DishesMenuRestaurantEntity[]; + @IsUUID() @Expose() @ApiProperty({ From 10d849282c0dd86c56e6a343cc55ed3de94cc6f6 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 16:31:10 +0200 Subject: [PATCH 139/180] feat: create and update methods for dishes-menus controller --- src/dishes-menus/dishes-menus.controller.ts | 41 ++- src/dishes-menus/dishes-menus.service.ts | 264 +++++++++++++++++++- src/i18n/messages/en/errors.json | 6 + 3 files changed, 309 insertions(+), 2 deletions(-) diff --git a/src/dishes-menus/dishes-menus.controller.ts b/src/dishes-menus/dishes-menus.controller.ts index a6dbcff..1d06f60 100644 --- a/src/dishes-menus/dishes-menus.controller.ts +++ b/src/dishes-menus/dishes-menus.controller.ts @@ -2,10 +2,12 @@ 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 } from "@nestjs/common"; +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 { 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") @@ -27,4 +29,41 @@ export class DishesMenusController { 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, + }); + } } diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts index 9748f04..5c53d5f 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -1,10 +1,19 @@ +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 } from "@postgress-db/schema/dishes-menus"; +import { + dishesMenus, + dishesMenusToRestaurants, +} from "@postgress-db/schema/dishes-menus"; import { and, eq, inArray, SQL } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; +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() @@ -13,6 +22,70 @@ export class DishesMenusService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} + /** + * 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, + }, + }, + }, + }, + }, + }); + + 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 { @@ -57,4 +130,193 @@ export class DishesMenusService { 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", + ); + } + } + + /** + * 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", + ); + } + + 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, + }); + + 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" + ) { + // 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)); + + if (restaurantIds) { + await tx + .delete(dishesMenusToRestaurants) + .where(eq(dishesMenusToRestaurants.dishesMenuId, id)); + + await tx.insert(dishesMenusToRestaurants).values( + restaurantIds.map((restaurantId) => ({ + dishesMenuId: id, + restaurantId, + })), + ); + } + }); + + return this.findOne(id, { + worker, + }); + } } diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 0af46ad..13aa422 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -103,5 +103,11 @@ "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-restaurants-are-not-owned-by-the-owner": "Some restaurants are not owned by the owner" } } From 14e10dd98143125931e7fa6465c7d186ba343404 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 16:53:08 +0200 Subject: [PATCH 140/180] feat: remove for the dishes menus --- src/@base/drizzle/schema/dishes-menus.ts | 5 ++ src/dishes-menus/dishes-menus.controller.ts | 14 ++- src/dishes-menus/dishes-menus.service.ts | 88 +++++++++++++++++-- .../dto/create-dishes-menu.dto.ts | 2 + src/dishes-menus/entity/dishes-menu.entity.ts | 25 +++++- src/i18n/messages/en/errors.json | 4 +- 6 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/@base/drizzle/schema/dishes-menus.ts b/src/@base/drizzle/schema/dishes-menus.ts index 3872cdc..799914d 100644 --- a/src/@base/drizzle/schema/dishes-menus.ts +++ b/src/@base/drizzle/schema/dishes-menus.ts @@ -3,6 +3,7 @@ import { restaurants } from "@postgress-db/schema/restaurants"; import { workers } from "@postgress-db/schema/workers"; import { relations } from "drizzle-orm"; import { + boolean, pgTable, primaryKey, text, @@ -19,9 +20,13 @@ export const dishesMenus = pgTable("dishesMenu", { // Owner of the menu // ownerId: uuid("ownerId").notNull(), + // Boolean flags // + isRemoved: boolean("isRemoved").notNull().default(false), + // Default timestamps // createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), + removedAt: timestamp("removedAt"), }); export type IDishesMenu = typeof dishesMenus.$inferSelect; diff --git a/src/dishes-menus/dishes-menus.controller.ts b/src/dishes-menus/dishes-menus.controller.ts index 1d06f60..6e3913f 100644 --- a/src/dishes-menus/dishes-menus.controller.ts +++ b/src/dishes-menus/dishes-menus.controller.ts @@ -2,7 +2,7 @@ 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 { 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"; @@ -66,4 +66,16 @@ export class DishesMenusController { 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.service.ts b/src/dishes-menus/dishes-menus.service.ts index 5c53d5f..88a6535 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -91,7 +91,10 @@ export class DishesMenusService { }): Promise { const { worker } = options; - const conditions: SQL[] = []; + const conditions: SQL[] = [ + // Exclude removed menus + eq(dishesMenus.isRemoved, false), + ]; if (worker.role === "SYSTEM_ADMIN" || worker.role === "CHIEF_ADMIN") { // Fetch all menus @@ -209,12 +212,14 @@ export class DishesMenusService { id: dishesMenus.id, }); - await tx.insert(dishesMenusToRestaurants).values( - restaurantIds.map((restaurantId) => ({ - dishesMenuId: createdMenu.id, - restaurantId, - })), - ); + if (restaurantIds.length > 0) { + await tx.insert(dishesMenusToRestaurants).values( + restaurantIds.map((restaurantId) => ({ + dishesMenuId: createdMenu.id, + restaurantId, + })), + ); + } return createdMenu.id; }); @@ -301,6 +306,7 @@ export class DishesMenusService { }) .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) @@ -319,4 +325,72 @@ export class DishesMenusService { 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 index 0459472..8f9e314 100644 --- a/src/dishes-menus/dto/create-dishes-menu.dto.ts +++ b/src/dishes-menus/dto/create-dishes-menu.dto.ts @@ -5,12 +5,14 @@ import { 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({ diff --git a/src/dishes-menus/entity/dishes-menu.entity.ts b/src/dishes-menus/entity/dishes-menu.entity.ts index c4dcef1..d6e3d03 100644 --- a/src/dishes-menus/entity/dishes-menu.entity.ts +++ b/src/dishes-menus/entity/dishes-menu.entity.ts @@ -1,4 +1,10 @@ -import { IsArray, IsISO8601, IsString, IsUUID } from "@i18n-class-validator"; +import { + IsArray, + IsBoolean, + IsISO8601, + IsString, + IsUUID, +} from "@i18n-class-validator"; import { ApiProperty, PickType } from "@nestjs/swagger"; import { IDishesMenu } from "@postgress-db/schema/dishes-menus"; import { Expose, Type } from "class-transformer"; @@ -43,6 +49,14 @@ export class DishesMenuEntity implements IDishesMenu { }) ownerId: string; + @IsBoolean() + // @Expose() + @ApiProperty({ + description: "Whether the menu is removed", + example: false, + }) + isRemoved: boolean; + @IsISO8601() @Expose() @ApiProperty({ @@ -60,4 +74,13 @@ export class DishesMenuEntity implements IDishesMenu { 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/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 13aa422..5617e6c 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -108,6 +108,8 @@ "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-restaurants-are-not-owned-by-the-owner": "Some restaurants are not owned by the owner" + "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" } } From f321336c8ac5236c0a9bd01cc40724cbf6e967ca Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 17:36:58 +0200 Subject: [PATCH 141/180] feat: attach owner to the dishes menus entity --- src/dishes-menus/dishes-menus.service.ts | 12 ++++++++++++ src/dishes-menus/entity/dishes-menu.entity.ts | 16 ++++++++++++++++ src/i18n/validators/index.ts | 8 +++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts index 88a6535..8d17e47 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -66,6 +66,12 @@ export class DishesMenusService { }, }, }, + owner: { + columns: { + id: true, + name: true, + }, + }, }, }); @@ -125,6 +131,12 @@ export class DishesMenusService { }, }, }, + owner: { + columns: { + id: true, + name: true, + }, + }, }, }); diff --git a/src/dishes-menus/entity/dishes-menu.entity.ts b/src/dishes-menus/entity/dishes-menu.entity.ts index d6e3d03..85f0c0a 100644 --- a/src/dishes-menus/entity/dishes-menu.entity.ts +++ b/src/dishes-menus/entity/dishes-menu.entity.ts @@ -2,6 +2,7 @@ import { IsArray, IsBoolean, IsISO8601, + IsObject, IsString, IsUUID, } from "@i18n-class-validator"; @@ -9,12 +10,18 @@ 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() @@ -49,6 +56,15 @@ export class DishesMenuEntity implements IDishesMenu { }) ownerId: string; + @Expose() + @IsObject() + @Type(() => DishesMenuOwnerEntity) + @ApiProperty({ + description: "Owner of the menu", + type: DishesMenuOwnerEntity, + }) + owner: DishesMenuOwnerEntity; + @IsBoolean() // @Expose() @ApiProperty({ diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts index e8efc68..0e11416 100644 --- a/src/i18n/validators/index.ts +++ b/src/i18n/validators/index.ts @@ -10,13 +10,14 @@ import { IsISO8601 as _IsISO8601, IsLatitude as _IsLatitude, 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, - Max as _Max, IsNumberOptions, ValidationOptions, } from "class-validator"; @@ -52,6 +53,11 @@ export const IsBoolean = (validationOptions?: ValidationOptions) => _IsBoolean(mergeI18nValidation("isBoolean", validationOptions)), ); +export const IsObject = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsObject(mergeI18nValidation("isObject", validationOptions)), + ); + export const IsUUID = ( version?: "3" | "4" | "5" | "all", validationOptions?: ValidationOptions, From 3002146e0d88342ca7d430e8827a59b18c311c70 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 18:25:47 +0200 Subject: [PATCH 142/180] feat: dishes menuId filter --- src/dishes/@/dishes.controller.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/dishes/@/dishes.controller.ts b/src/dishes/@/dishes.controller.ts index 1f137bd..bbab651 100644 --- a/src/dishes/@/dishes.controller.ts +++ b/src/dishes/@/dishes.controller.ts @@ -13,7 +13,7 @@ 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 { Body, Get, Param, Post, Put, Query } from "@nestjs/common"; import { ApiBadRequestResponse, ApiCreatedResponse, @@ -21,6 +21,7 @@ import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiQuery, ApiUnauthorizedResponse, } from "@nestjs/swagger"; import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; @@ -47,6 +48,12 @@ export class DishesController { 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: [ @@ -62,6 +69,8 @@ export class DishesController { @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) { @@ -75,6 +84,18 @@ export class DishesController { }); } + if (menuId) { + 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({ From 8dacacde2e297de52eaf8d8429246bb6018e0bcc Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 19:27:00 +0200 Subject: [PATCH 143/180] fix: else if statement for update dishes menus --- src/dishes-menus/dishes-menus.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts index 8d17e47..e79a90c 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -291,7 +291,9 @@ export class DishesMenusService { throw new ForbiddenException("errors.dishes-menus.not-enough-rights"); } else if ( worker.role !== "SYSTEM_ADMIN" && - worker.role !== "CHIEF_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"); From 447e7fc78014e8a02e337dcd40173d2696777481 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 19:29:30 +0200 Subject: [PATCH 144/180] fix: update restaurant ids --- src/dishes-menus/dishes-menus.service.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts index e79a90c..d7b8813 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -9,7 +9,7 @@ import { dishesMenus, dishesMenusToRestaurants, } from "@postgress-db/schema/dishes-menus"; -import { and, eq, inArray, SQL } from "drizzle-orm"; +import { and, desc, eq, inArray, SQL } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; import { CreateDishesMenuDto } from "src/dishes-menus/dto/create-dishes-menu.dto"; @@ -138,6 +138,7 @@ export class DishesMenusService { }, }, }, + orderBy: [desc(dishesMenus.createdAt)], }); return fetchedMenus.map(({ dishesMenusToRestaurants, ...dishesMenu }) => ({ @@ -326,12 +327,14 @@ export class DishesMenusService { .delete(dishesMenusToRestaurants) .where(eq(dishesMenusToRestaurants.dishesMenuId, id)); - await tx.insert(dishesMenusToRestaurants).values( - restaurantIds.map((restaurantId) => ({ - dishesMenuId: id, - restaurantId, - })), - ); + if (restaurantIds.length > 0) { + await tx.insert(dishesMenusToRestaurants).values( + restaurantIds.map((restaurantId) => ({ + dishesMenuId: id, + restaurantId, + })), + ); + } } }); From f524c557d312e037888eeda3c8f76a37601eb122 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 19:53:27 +0200 Subject: [PATCH 145/180] feat: define property for some errors and min length for dishes menu name --- src/dishes-menus/dishes-menus.service.ts | 6 ++++++ src/dishes-menus/entity/dishes-menu.entity.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts index d7b8813..99c5304 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -166,6 +166,9 @@ export class DishesMenusService { if (restaurants.length !== restaurantIds.length) { throw new BadRequestException( "errors.dishes-menus.some-restaurant-are-not-owned-by-the-owner", + { + property: "restaurantIds", + }, ); } } @@ -199,6 +202,9 @@ export class DishesMenusService { if (!payload.ownerId) { throw new BadRequestException( "errors.dishes-menus.owner-id-is-required", + { + property: "ownerId", + }, ); } diff --git a/src/dishes-menus/entity/dishes-menu.entity.ts b/src/dishes-menus/entity/dishes-menu.entity.ts index 85f0c0a..6f6b4ae 100644 --- a/src/dishes-menus/entity/dishes-menu.entity.ts +++ b/src/dishes-menus/entity/dishes-menu.entity.ts @@ -5,6 +5,7 @@ import { IsObject, IsString, IsUUID, + MinLength, } from "@i18n-class-validator"; import { ApiProperty, PickType } from "@nestjs/swagger"; import { IDishesMenu } from "@postgress-db/schema/dishes-menus"; @@ -32,6 +33,7 @@ export class DishesMenuEntity implements IDishesMenu { id: string; @IsString() + @MinLength(3) @Expose() @ApiProperty({ description: "Name of the menu", From 3e8cb132463953e131500a18c65aa1412839885c Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 20:01:10 +0200 Subject: [PATCH 146/180] feat: menuId query param for restaurants --- src/dishes-menus/dishes-menus.service.ts | 5 ++++ .../@/controllers/restaurants.controller.ts | 11 +++++++- .../@/services/restaurants.service.ts | 26 +++++++++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts index 99c5304..9923dad 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -22,6 +22,11 @@ export class DishesMenusService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} + private async onApplicationBootstrap() { + // TODO: create default menu for each owner that doesn't have one + // should be done with redis lock + } + /** * Fetches a dish menu by its ID * @param id - The ID of the dish menu diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts index eb6653f..f5b1f17 100644 --- a/src/restaurants/@/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -6,7 +6,7 @@ import { 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, Post, Put } from "@nestjs/common"; +import { Body, Delete, Get, Param, Post, Put, Query } from "@nestjs/common"; import { ApiCreatedResponse, ApiForbiddenResponse, @@ -14,6 +14,7 @@ import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiQuery, ApiUnauthorizedResponse, } from "@nestjs/swagger"; import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; @@ -42,14 +43,22 @@ export class RestaurantsController { description: "Restaurants have been successfully fetched", type: RestaurantsPaginatedDto, }) + @ApiQuery({ + name: "menuId", + description: "Filter out restaurants that was assigned to a menu", + type: String, + required: false, + }) async findAll( @PaginationParams() pagination: IPagination, @Worker() worker: RequestWorker, + @Query("menuId") menuId?: string, ) { const total = await this.restaurantsService.getTotalCount(); const data = await this.restaurantsService.findMany({ pagination, worker, + menuId, }); return { diff --git a/src/restaurants/@/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts index 8684ca8..1fd3d35 100644 --- a/src/restaurants/@/services/restaurants.service.ts +++ b/src/restaurants/@/services/restaurants.service.ts @@ -4,7 +4,9 @@ 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 { and, count, eq, inArray, SQL } from "drizzle-orm"; +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"; @@ -39,8 +41,9 @@ export class RestaurantsService { public async findMany(options: { pagination: IPagination; worker?: RequestWorker; + menuId?: string; }): Promise { - const { pagination, worker } = options; + const { pagination, worker, menuId } = options; const conditions: SQL[] = []; @@ -64,6 +67,25 @@ export class RestaurantsService { } } + // 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), + ), + ), + ), + ); + } + return await this.pg.query.restaurants.findMany({ ...(conditions.length > 0 ? { where: () => and(...conditions) } : {}), limit: pagination.size, From 63024a86290dfd0b4293f33dd55c3a3f40c7f366 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 20:04:36 +0200 Subject: [PATCH 147/180] feat: ownerId filter for the restaurants --- src/restaurants/@/controllers/restaurants.controller.ts | 8 ++++++++ src/restaurants/@/services/restaurants.service.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts index f5b1f17..490c657 100644 --- a/src/restaurants/@/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -49,16 +49,24 @@ export class RestaurantsController { 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, worker, menuId, + ownerId, }); return { diff --git a/src/restaurants/@/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts index 1fd3d35..52ad33a 100644 --- a/src/restaurants/@/services/restaurants.service.ts +++ b/src/restaurants/@/services/restaurants.service.ts @@ -26,6 +26,7 @@ export class RestaurantsService { * Gets total count of restaurants * @returns */ + // TODO: add menuId and ownerId filters public async getTotalCount(): Promise { return await this.pg .select({ value: count() }) @@ -41,9 +42,11 @@ export class RestaurantsService { public async findMany(options: { pagination: IPagination; worker?: RequestWorker; + // TODO: replace with filters menuId?: string; + ownerId?: string; }): Promise { - const { pagination, worker, menuId } = options; + const { pagination, worker, menuId, ownerId } = options; const conditions: SQL[] = []; @@ -86,6 +89,10 @@ export class RestaurantsService { ); } + 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, From bc6d278d051bb1ab1f63068bf94158d7bc56efd6 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 20:18:31 +0200 Subject: [PATCH 148/180] feat: redlock module --- package.json | 1 + src/@base/redis/channels.ts | 1 + src/@base/redlock/redlock.module.ts | 10 +++++++ src/@base/redlock/redlock.service.ts | 40 ++++++++++++++++++++++++++++ src/app.module.ts | 2 ++ yarn.lock | 5 ++++ 6 files changed, 59 insertions(+) create mode 100644 src/@base/redlock/redlock.module.ts create mode 100644 src/@base/redlock/redlock.service.ts diff --git a/package.json b/package.json index c49ae91..3b57531 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@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", diff --git a/src/@base/redis/channels.ts b/src/@base/redis/channels.ts index 2783cb0..f3e9455 100644 --- a/src/@base/redis/channels.ts +++ b/src/@base/redis/channels.ts @@ -2,4 +2,5 @@ export enum RedisChannels { COMMON = 1, BULLMQ = 2, SOCKET = 3, + REDLOCK = 4, } 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/app.module.ts b/src/app.module.ts index c8170c5..e7dd10e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { AuditLogsInterceptor } from "src/@base/audit-logs/audit-logs.intercepto 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"; @@ -77,6 +78,7 @@ import { WorkersModule } from "./workers/workers.module"; TimezonesModule, AuthModule, WorkersModule, + RedlockModule, RestaurantsModule, AddressesModule, GuestsModule, diff --git a/yarn.lock b/yarn.lock index ccfc0dd..793dab1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1818,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" From 1759b251073cf2d85be41252f184b688887cc78f Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 6 Mar 2025 20:43:51 +0200 Subject: [PATCH 149/180] feat: create default dish menu for restaurant owner if it don't have any --- src/dishes-menus/dishes-menus.module.ts | 15 +++- src/dishes-menus/dishes-menus.processor.ts | 85 ++++++++++++++++++++++ src/dishes-menus/dishes-menus.producer.ts | 38 ++++++++++ src/dishes-menus/dishes-menus.service.ts | 5 +- src/dishes-menus/index.ts | 5 ++ 5 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 src/dishes-menus/dishes-menus.processor.ts create mode 100644 src/dishes-menus/dishes-menus.producer.ts create mode 100644 src/dishes-menus/index.ts diff --git a/src/dishes-menus/dishes-menus.module.ts b/src/dishes-menus/dishes-menus.module.ts index 77f8bcf..713022b 100644 --- a/src/dishes-menus/dishes-menus.module.ts +++ b/src/dishes-menus/dishes-menus.module.ts @@ -1,12 +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], + imports: [ + DrizzleModule, + RedlockModule, + BullModule.registerQueue({ + name: DISHES_MENUS_QUEUE, + }), + ], controllers: [DishesMenusController], - providers: [DishesMenusService], + 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 index 9923dad..f404709 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -12,6 +12,7 @@ import { 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"; @@ -20,11 +21,11 @@ import { DishesMenuEntity } from "src/dishes-menus/entity/dishes-menu.entity"; export class DishesMenusService { constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly dishesMenusProducer: DishesMenusProducer, ) {} private async onApplicationBootstrap() { - // TODO: create default menu for each owner that doesn't have one - // should be done with redis lock + await this.dishesMenusProducer.createOwnersDefaultMenu(); } /** 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", +} From 6d9cbd92b0a6c73f3377d45cd1d706b783239d61 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Mar 2025 13:33:13 +0200 Subject: [PATCH 150/180] refactor: snake_case for drizzle postgres database to prevent errors --- src/@base/drizzle/schema/discounts.ts | 30 ++++++------ src/@base/drizzle/schema/dish-categories.ts | 15 +++--- src/@base/drizzle/schema/dish-modifiers.ts | 20 ++++---- src/@base/drizzle/schema/dishes-menus.ts | 16 +++---- src/@base/drizzle/schema/dishes.ts | 40 ++++++++-------- src/@base/drizzle/schema/files.ts | 12 ++--- src/@base/drizzle/schema/guests.ts | 8 ++-- src/@base/drizzle/schema/many-to-many.ts | 14 +++--- src/@base/drizzle/schema/order-deliveries.ts | 16 +++---- src/@base/drizzle/schema/order-dishes.ts | 38 +++++++-------- src/@base/drizzle/schema/orders.ts | 46 +++++++++---------- src/@base/drizzle/schema/payment-methods.ts | 18 ++++---- .../drizzle/schema/restaurant-workshop.ts | 20 ++++---- src/@base/drizzle/schema/restaurants.ts | 31 +++++++------ src/@base/drizzle/schema/sessions.ts | 16 +++---- src/@base/drizzle/schema/workers.ts | 20 ++++---- src/dishes-menus/dishes-menus.service.ts | 1 + 17 files changed, 183 insertions(+), 178 deletions(-) diff --git a/src/@base/drizzle/schema/discounts.ts b/src/@base/drizzle/schema/discounts.ts index 037043f..b77a0db 100644 --- a/src/@base/drizzle/schema/discounts.ts +++ b/src/@base/drizzle/schema/discounts.ts @@ -26,28 +26,28 @@ export const discounts = pgTable("discounts", { percent: integer("percent").notNull().default(0), // Basic conditions // - orderFroms: orderFromEnum("orderFroms").array().notNull(), - orderTypes: orderTypeEnum("orderTypes").array().notNull(), - daysOfWeek: dayOfWeekEnum("daysOfWeek").array().notNull(), + 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("applyByPromocode").notNull().default(false), - applyForFirstOrder: boolean("applyForFirstOrder").notNull().default(false), - applyByDefault: boolean("applyByDefault").notNull().default(false), + 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("startHour"), - endHour: integer("endHour"), - activeFrom: timestamp("activeFrom").notNull(), - activeTo: timestamp("activeTo").notNull(), + startHour: integer("start_hour"), + endHour: integer("end_hour"), + activeFrom: timestamp("active_from").notNull(), + activeTo: timestamp("active_to").notNull(), // Timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export type IDiscount = typeof discounts.$inferSelect; @@ -57,10 +57,10 @@ export const discountRelations = relations(discounts, ({ many }) => ({ })); export const discountsToRestaurants = pgTable( - "discountsToRestaurants", + "discounts_to_restaurants", { - discountId: uuid("discountId").notNull(), - restaurantId: uuid("restaurantId").notNull(), + discountId: uuid("discount_id").notNull(), + restaurantId: uuid("restaurant_id").notNull(), }, (t) => [ primaryKey({ diff --git a/src/@base/drizzle/schema/dish-categories.ts b/src/@base/drizzle/schema/dish-categories.ts index 13a00d6..f21bf63 100644 --- a/src/@base/drizzle/schema/dish-categories.ts +++ b/src/@base/drizzle/schema/dish-categories.ts @@ -9,24 +9,27 @@ import { uuid, } from "drizzle-orm/pg-core"; -export const dishCategories = pgTable("dishCategories", { +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("showForWorkers").notNull().default(false), + showForWorkers: boolean("show_for_workers").notNull().default(false), // Will category be visible for guests at site and in app // - showForGuests: boolean("showForGuests").notNull().default(false), + showForGuests: boolean("show_for_guests").notNull().default(false), // Sorting index in the admin menu // - sortIndex: integer("sortIndex").notNull(), + sortIndex: integer("sort_index").notNull(), // Default timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export type IDishCategory = typeof dishCategories.$inferSelect; diff --git a/src/@base/drizzle/schema/dish-modifiers.ts b/src/@base/drizzle/schema/dish-modifiers.ts index 7eb8992..181798d 100644 --- a/src/@base/drizzle/schema/dish-modifiers.ts +++ b/src/@base/drizzle/schema/dish-modifiers.ts @@ -10,32 +10,32 @@ import { uuid, } from "drizzle-orm/pg-core"; -export const dishModifiers = pgTable("dishModifiers", { +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("restaurantId").notNull(), + restaurantId: uuid("restaurant_id").notNull(), // Boolean fields // - isActive: boolean("isActive").notNull().default(true), - isRemoved: boolean("isRemoved").notNull().default(false), + isActive: boolean("is_active").notNull().default(true), + isRemoved: boolean("is_removed").notNull().default(false), // Default timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), - removedAt: timestamp("removedAt"), + 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( - "dishModifiersToOrderDishes", + "dish_modifiers_to_order_dishes", { - dishModifierId: uuid("dishModifierId").notNull(), - orderDishId: uuid("orderDishId").notNull(), + dishModifierId: uuid("dish_modifier_id").notNull(), + orderDishId: uuid("order_dish_id").notNull(), }, (t) => [primaryKey({ columns: [t.dishModifierId, t.orderDishId] })], ); diff --git a/src/@base/drizzle/schema/dishes-menus.ts b/src/@base/drizzle/schema/dishes-menus.ts index 799914d..52c3cec 100644 --- a/src/@base/drizzle/schema/dishes-menus.ts +++ b/src/@base/drizzle/schema/dishes-menus.ts @@ -11,22 +11,22 @@ import { uuid, } from "drizzle-orm/pg-core"; -export const dishesMenus = pgTable("dishesMenu", { +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("ownerId").notNull(), + ownerId: uuid("owner_id").notNull(), // Boolean flags // - isRemoved: boolean("isRemoved").notNull().default(false), + isRemoved: boolean("is_removed").notNull().default(false), // Default timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), - removedAt: timestamp("removedAt"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + removedAt: timestamp("removed_at"), }); export type IDishesMenu = typeof dishesMenus.$inferSelect; @@ -34,8 +34,8 @@ export type IDishesMenu = typeof dishesMenus.$inferSelect; export const dishesMenusToRestaurants = pgTable( "dishesMenusToRestaurants", { - restaurantId: uuid("restaurantId").notNull(), - dishesMenuId: uuid("dishesMenuId").notNull(), + restaurantId: uuid("restaurant_id").notNull(), + dishesMenuId: uuid("dishes_menu_id").notNull(), }, (t) => [ primaryKey({ diff --git a/src/@base/drizzle/schema/dishes.ts b/src/@base/drizzle/schema/dishes.ts index 5de80a5..520e84b 100644 --- a/src/@base/drizzle/schema/dishes.ts +++ b/src/@base/drizzle/schema/dishes.ts @@ -34,7 +34,7 @@ export const dishes = pgTable("dishes", { id: uuid("id").defaultRandom().primaryKey(), // Menu // - menuId: uuid("menuId"), + menuId: uuid("menu_id"), // Name of the dish // name: text("name").notNull().default(""), @@ -43,42 +43,42 @@ export const dishes = pgTable("dishes", { note: text("note").notNull().default(""), // How much time is needed for cooking // - cookingTimeInMin: integer("cookingTimeInMin").notNull().default(0), + cookingTimeInMin: integer("cooking_time_in_min").notNull().default(0), // How many pcs in one item (for example: 6 hinkali per one item) // - amountPerItem: integer("amountPerItem").notNull().default(1), + amountPerItem: integer("amount_per_item").notNull().default(1), // Weight of the dish // weight: integer("weight").notNull().default(0), - weightMeasure: weightMeasureEnum("weightMeasure").notNull().default("grams"), + weightMeasure: weightMeasureEnum("weight_measure").notNull().default("grams"), // Label printing // - isLabelPrintingEnabled: boolean("isLabelPrintingEnabled") + isLabelPrintingEnabled: boolean("is_label_printing_enabled") .notNull() .default(false), - printLabelEveryItem: integer("printLabelEveryItem").notNull().default(0), + printLabelEveryItem: integer("print_label_every_item").notNull().default(0), // Publishing booleans // - isPublishedInApp: boolean("isPublishedInApp").notNull().default(false), - isPublishedAtSite: boolean("isPublishedAtSite").notNull().default(false), + isPublishedInApp: boolean("is_published_in_app").notNull().default(false), + isPublishedAtSite: boolean("is_published_at_site").notNull().default(false), // Default timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export type IDish = typeof dishes.$inferSelect; export const dishesToRestaurants = pgTable( - "dishesToRestaurants", + "dishes_to_restaurants", { - dishId: uuid("dishId").notNull(), - restaurantId: uuid("restaurantId").notNull(), + 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("isInStopList").notNull().default(false), - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + isInStopList: boolean("is_in_stop_list").notNull().default(false), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }, (t) => [ primaryKey({ @@ -104,11 +104,11 @@ export const dishesToRestaurantsRelations = relations( ); export const dishesToWorkshops = pgTable( - "dishesToWorkshops", + "dishes_to_workshops", { - dishId: uuid("dishId").notNull(), - workshopId: uuid("workshopId").notNull(), - createdAt: timestamp("createdAt").notNull().defaultNow(), + dishId: uuid("dish_id").notNull(), + workshopId: uuid("workshop_id").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), }, (t) => [ primaryKey({ diff --git a/src/@base/drizzle/schema/files.ts b/src/@base/drizzle/schema/files.ts index ee6f022..f1a5848 100644 --- a/src/@base/drizzle/schema/files.ts +++ b/src/@base/drizzle/schema/files.ts @@ -6,19 +6,19 @@ export const files = pgTable("files", { id: uuid("id").defaultRandom().primaryKey(), // File group id // - groupId: uuid("groupId"), + groupId: uuid("group_id"), // Original name of the file // - originalName: text("originalName").notNull(), + originalName: text("original_name").notNull(), // Mime type of the file // - mimeType: text("mimeType").notNull(), + mimeType: text("mime_type").notNull(), // Extension of the file // extension: text("extension").notNull(), // Bucket name // - bucketName: text("bucketName").notNull(), + bucketName: text("bucket_name").notNull(), // Region of the file // region: text("region").notNull(), @@ -30,10 +30,10 @@ export const files = pgTable("files", { size: integer("size").notNull().default(0), // Uploaded by user id // - uploadedByUserId: uuid("uploadedByUserId"), + uploadedByUserId: uuid("uploaded_by_user_id"), // Created at // - createdAt: timestamp("createdAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), }); export type IFile = typeof files.$inferSelect; diff --git a/src/@base/drizzle/schema/guests.ts b/src/@base/drizzle/schema/guests.ts index 8382557..fc5d039 100644 --- a/src/@base/drizzle/schema/guests.ts +++ b/src/@base/drizzle/schema/guests.ts @@ -7,10 +7,10 @@ export const guests = pgTable("guests", { name: text("name").notNull().default(""), phone: text("phone").unique().notNull(), email: text("email").unique(), - bonusBalance: integer("bonusBalance").notNull().default(0), - lastVisitAt: timestamp("lastVisitAt").notNull().defaultNow(), - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + 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; diff --git a/src/@base/drizzle/schema/many-to-many.ts b/src/@base/drizzle/schema/many-to-many.ts index d4ee4c1..49388e4 100644 --- a/src/@base/drizzle/schema/many-to-many.ts +++ b/src/@base/drizzle/schema/many-to-many.ts @@ -8,10 +8,10 @@ import { integer, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core"; // Dishes to dish categories relation // // ----------------------------------- // export const dishesToCategories = pgTable( - "dishesToCategories", + "dishes_to_categories", { - dishId: uuid("dishId").notNull(), - dishCategoryId: uuid("dishCategoryId").notNull(), + dishId: uuid("dish_id").notNull(), + dishCategoryId: uuid("dish_category_id").notNull(), }, (t) => [ primaryKey({ @@ -40,12 +40,12 @@ export const dishesToCategoriesRelations = relations( // Dishes to images relation // // ----------------------------------- // export const dishesToImages = pgTable( - "dishesToImages", + "dishes_to_images", { - dishId: uuid("dishId").notNull(), - imageFileId: uuid("imageFileId").notNull(), + dishId: uuid("dish_id").notNull(), + imageFileId: uuid("image_file_id").notNull(), alt: text("alt").notNull().default(""), - sortIndex: integer("sortIndex").notNull().default(0), + sortIndex: integer("sort_index").notNull().default(0), }, (t) => [ primaryKey({ diff --git a/src/@base/drizzle/schema/order-deliveries.ts b/src/@base/drizzle/schema/order-deliveries.ts index 960ff27..1c0a919 100644 --- a/src/@base/drizzle/schema/order-deliveries.ts +++ b/src/@base/drizzle/schema/order-deliveries.ts @@ -24,12 +24,12 @@ export const ZodOrderDeliveryStatusEnum = z.enum( export type OrderDeliveryStatusEnum = typeof ZodOrderDeliveryStatusEnum._type; -export const orderDeliveries = pgTable("orderDeliveries", { +export const orderDeliveries = pgTable("order_deliveries", { id: uuid("id").defaultRandom().primaryKey(), // Relation fields // - orderId: uuid("orderId").notNull(), - workerId: uuid("workerId"), + orderId: uuid("order_id").notNull(), + workerId: uuid("worker_id"), // Data // status: orderDeliveryStatusEnum("status").notNull(), @@ -40,11 +40,11 @@ export const orderDeliveries = pgTable("orderDeliveries", { price: decimal("price", { precision: 10, scale: 2 }).notNull(), // Timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), - dispatchedAt: timestamp("dispatchedAt"), - estimatedDeliveryAt: timestamp("estimatedDeliveryAt"), - deliveredAt: timestamp("deliveredAt"), + 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; diff --git a/src/@base/drizzle/schema/order-dishes.ts b/src/@base/drizzle/schema/order-dishes.ts index bc7ff4d..d80fa06 100644 --- a/src/@base/drizzle/schema/order-dishes.ts +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -27,15 +27,15 @@ export const ZodOrderDishStatusEnum = z.enum(orderDishStatusEnum.enumValues); export type OrderDishStatusEnum = typeof ZodOrderDishStatusEnum._type; export const orderDishes = pgTable( - "orderDishes", + "order_dishes", { id: uuid("id").defaultRandom().primaryKey(), // Relation fields // - orderId: uuid("orderId").notNull(), - dishId: uuid("dishId").notNull(), - discountId: uuid("discountId"), - surchargeId: uuid("surchargeId"), + orderId: uuid("order_id").notNull(), + dishId: uuid("dish_id").notNull(), + discountId: uuid("discount_id"), + surchargeId: uuid("surcharge_id"), // Data // name: text("name").notNull(), @@ -43,36 +43,36 @@ export const orderDishes = pgTable( // Quantity // quantity: integer("quantity").notNull(), - quantityReturned: integer("quantityReturned").notNull().default(0), + quantityReturned: integer("quantity_returned").notNull().default(0), // Price info // price: decimal("price", { precision: 10, scale: 2 }).notNull(), - discountPercent: decimal("discountPercent", { precision: 10, scale: 2 }) + discountPercent: decimal("discount_percent", { precision: 10, scale: 2 }) .notNull() .default("0"), - discountAmount: decimal("discountAmount", { precision: 10, scale: 2 }) + discountAmount: decimal("discount_amount", { precision: 10, scale: 2 }) .notNull() .default("0"), - surchargePercent: decimal("surchargePercent", { precision: 10, scale: 2 }) + surchargePercent: decimal("surcharge_percent", { precision: 10, scale: 2 }) .notNull() .default("0"), - surchargeAmount: decimal("surchargeAmount", { precision: 10, scale: 2 }) + surchargeAmount: decimal("surcharge_amount", { precision: 10, scale: 2 }) .notNull() .default("0"), - finalPrice: decimal("finalPrice", { precision: 10, scale: 2 }).notNull(), + finalPrice: decimal("final_price", { precision: 10, scale: 2 }).notNull(), // Booleans flags // - isRemoved: boolean("isRemoved").notNull().default(false), - isAdditional: boolean("isAdditional").notNull().default(false), + isRemoved: boolean("is_removed").notNull().default(false), + isAdditional: boolean("is_additional").notNull().default(false), // Timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), - cookingAt: timestamp("cookingAt"), - readyAt: timestamp("readyAt"), - removedAt: timestamp("removedAt"), + 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("orderDishes_orderId_idx").on(table.orderId)], + (table) => [index("order_dishes_order_id_idx").on(table.orderId)], ); export type IOrderDish = typeof orderDishes.$inferSelect; diff --git a/src/@base/drizzle/schema/orders.ts b/src/@base/drizzle/schema/orders.ts index 483e9f0..992ee69 100644 --- a/src/@base/drizzle/schema/orders.ts +++ b/src/@base/drizzle/schema/orders.ts @@ -53,10 +53,10 @@ export const ZodOrderTypeEnum = z.enum(orderTypeEnum.enumValues); export type OrderTypeEnum = typeof ZodOrderTypeEnum._type; -export const orderNumberBroneering = pgTable("orderNumberBroneering", { +export const orderNumberBroneering = pgTable("order_number_broneering", { id: uuid("id").defaultRandom().primaryKey(), number: text("number").notNull(), - createdAt: timestamp("createdAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), }); export const orders = pgTable( @@ -65,13 +65,13 @@ export const orders = pgTable( id: uuid("id").defaultRandom().primaryKey(), // Links // - guestId: uuid("guestId"), - restaurantId: uuid("restaurantId"), - paymentMethodId: uuid("paymentMethodId"), + guestId: uuid("guest_id"), + restaurantId: uuid("restaurant_id"), + paymentMethodId: uuid("payment_method_id"), // Order number // number: text("number").notNull(), - tableNumber: text("tableNumber"), + tableNumber: text("table_number"), // Order type // type: orderTypeEnum("type").notNull(), @@ -83,18 +83,18 @@ export const orders = pgTable( note: text("note"), // Guest information // - guestName: text("guestName"), - guestPhone: text("guestPhone"), - guestsAmount: integer("guestsAmount"), + 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("discountAmount", { precision: 10, scale: 2 }) + discountAmount: decimal("discount_amount", { precision: 10, scale: 2 }) .notNull() .default("0"), - surchargeAmount: decimal("surchargeAmount", { precision: 10, scale: 2 }) + surchargeAmount: decimal("surcharge_amount", { precision: 10, scale: 2 }) .notNull() .default("0"), bonusUsed: decimal("bonusUsed", { precision: 10, scale: 2 }) @@ -103,23 +103,23 @@ export const orders = pgTable( total: decimal("total", { precision: 10, scale: 2 }).notNull().default("0"), // Booleans flags // - isHiddenForGuest: boolean("isHiddenForGuest").notNull().default(false), - isRemoved: boolean("isRemoved").notNull().default(false), - isArchived: boolean("isArchived").notNull().default(false), + isHiddenForGuest: boolean("is_hidden_for_guest").notNull().default(false), + isRemoved: boolean("is_removed").notNull().default(false), + isArchived: boolean("is_archived").notNull().default(false), // Default timestamps - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), - cookingAt: timestamp("cookingAt"), - completedAt: timestamp("completedAt"), - removedAt: timestamp("removedAt"), - delayedTo: timestamp("delayedTo"), + 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_restaurantId_idx").on(table.restaurantId), + index("orders_restaurant_id_idx").on(table.restaurantId), index("orders_created_at_idx").on(table.createdAt), - index("orders_isArchived_idx").on(table.isArchived), - index("orders_isRemoved_idx").on(table.isRemoved), + 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), ], ); diff --git a/src/@base/drizzle/schema/payment-methods.ts b/src/@base/drizzle/schema/payment-methods.ts index 81b26b5..382043c 100644 --- a/src/@base/drizzle/schema/payment-methods.ts +++ b/src/@base/drizzle/schema/payment-methods.ts @@ -24,7 +24,7 @@ export const paymentMethodIconEnum = pgEnum("payment_method_icon", [ export type IPaymentMethodType = (typeof paymentMethodTypeEnum.enumValues)[number]; -export const paymentMethods = pgTable("paymentMethods", { +export const paymentMethods = pgTable("payment_methods", { id: uuid("id").defaultRandom().primaryKey(), // For example "Yoo Kassa" or "Cash"/"Card" // @@ -32,20 +32,20 @@ export const paymentMethods = pgTable("paymentMethods", { type: paymentMethodTypeEnum("type").notNull(), icon: paymentMethodIconEnum("icon").notNull(), - restaurantId: uuid("restaurantId").notNull(), + restaurantId: uuid("restaurant_id").notNull(), // For YOO_KASSA // - secretId: text("secretId"), - secretKey: text("secretKey"), + secretId: text("secret_id"), + secretKey: text("secret_key"), // Boolean fields // - isActive: boolean("isActive").notNull().default(false), - isRemoved: boolean("isRemoved").notNull().default(false), + isActive: boolean("is_active").notNull().default(false), + isRemoved: boolean("is_removed").notNull().default(false), // Default timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), - removedAt: timestamp("removedAt"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + removedAt: timestamp("removed_at"), }); export type IPaymentMethod = typeof paymentMethods.$inferSelect; diff --git a/src/@base/drizzle/schema/restaurant-workshop.ts b/src/@base/drizzle/schema/restaurant-workshop.ts index 789bdf0..cb2c687 100644 --- a/src/@base/drizzle/schema/restaurant-workshop.ts +++ b/src/@base/drizzle/schema/restaurant-workshop.ts @@ -5,27 +5,27 @@ import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { restaurants } from "./restaurants"; import { workers } from "./workers"; -export const restaurantWorkshops = pgTable("restaurantWorkshop", { +export const restaurantWorkshops = pgTable("restaurant_workshops", { // Primary key // id: uuid("id").defaultRandom().primaryKey(), // Restaurant // - restaurantId: uuid("restaurantId").notNull(), + restaurantId: uuid("restaurant_id").notNull(), // Name of the workshop // name: text("name").notNull(), // Is label printing enabled? // - isLabelPrintingEnabled: boolean("isLabelPrintingEnabled") + isLabelPrintingEnabled: boolean("is_label_printing_enabled") .notNull() .default(false), // Is enabled? // - isEnabled: boolean("isEnabled").notNull().default(true), + isEnabled: boolean("is_enabled").notNull().default(true), // Timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export const restaurantWorkshopRelations = relations( @@ -42,14 +42,14 @@ export const restaurantWorkshopRelations = relations( export type IRestaurantWorkshop = typeof restaurantWorkshops.$inferSelect; -export const workshopWorkers = pgTable("workshopWorkers", { - workerId: uuid("workerId") +export const workshopWorkers = pgTable("workshop_workers", { + workerId: uuid("worker_id") .notNull() .references(() => workers.id), - workshopId: uuid("workshopId") + workshopId: uuid("workshop_id") .notNull() .references(() => restaurantWorkshops.id), - createdAt: timestamp("createdAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), }); export const workshopWorkerRelations = relations( diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index bc18fc9..236eb8e 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -26,7 +26,7 @@ export const restaurants = pgTable("restaurants", { name: text("name").notNull(), // Legal entity of the restaurant (can be a company or a person) // - legalEntity: text("legalEntity").notNull(), + legalEntity: text("legal_entity").notNull(), // Address of the restaurant // address: text("address").notNull(), @@ -40,41 +40,42 @@ export const restaurants = pgTable("restaurants", { currency: currencyEnum("currency").notNull().default("EUR"), // Country code of the restaurant (used for mobile phone default and etc.) // - countryCode: text("countryCode").notNull().default("EE"), + countryCode: text("country_code").notNull().default("EE"), // Is the restaurant enabled? // - isEnabled: boolean("isEnabled").notNull().default(false), + isEnabled: boolean("is_enabled").notNull().default(false), // Is closed forever? // - isClosedForever: boolean("isClosedForever").notNull().default(false), + isClosedForever: boolean("is_closed_forever").notNull().default(false), // Owner of the restaurant // - ownerId: uuid("ownerId"), + ownerId: uuid("owner_id"), // Timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -export const restaurantHours = pgTable("restaurantHours", { +export const restaurantHours = pgTable("restaurant_hours", { // Primary key // id: uuid("id").defaultRandom().primaryKey(), // Restaurant // - restaurantId: uuid("restaurantId").notNull(), + restaurantId: uuid("restaurant_id").notNull(), // Day of the week // - dayOfWeek: dayOfWeekEnum("dayOfWeek").notNull(), + dayOfWeek: dayOfWeekEnum("day_of_week").notNull(), // Opening and closing hours // - openingTime: text("openingTime").notNull(), - closingTime: text("closingTime").notNull(), + openingTime: text("opening_time").notNull(), + closingTime: text("closing_time").notNull(), - isEnabled: boolean("isEnabled").notNull().default(true), + // Is enabled? // + isEnabled: boolean("is_enabled").notNull().default(true), // Timestamps // - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export const restaurantRelations = relations(restaurants, ({ one, many }) => ({ diff --git a/src/@base/drizzle/schema/sessions.ts b/src/@base/drizzle/schema/sessions.ts index 9f95d33..8fd33ed 100644 --- a/src/@base/drizzle/schema/sessions.ts +++ b/src/@base/drizzle/schema/sessions.ts @@ -5,15 +5,15 @@ import { workers } from "./workers"; export const sessions = pgTable("sessions", { id: uuid("id").primaryKey().defaultRandom(), - previousId: uuid("previousId"), - workerId: uuid("workerId").notNull(), - httpAgent: text("httpAgent"), + previousId: uuid("previous_id"), + workerId: uuid("worker_id").notNull(), + httpAgent: text("http_agent"), ip: text("ip"), - isActive: boolean("isActive").notNull().default(true), - onlineAt: timestamp("onlineAt").notNull().defaultNow(), - refreshedAt: timestamp("refreshedAt").notNull().defaultNow(), - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + 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 }) => ({ diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 7be5197..2b64d81 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -46,20 +46,20 @@ export const workers = pgTable("workers", { name: text("name").notNull().default("N/A"), login: text("login").unique().notNull(), role: workerRoleEnum("role").notNull(), - passwordHash: text("passwordHash").notNull(), - isBlocked: boolean("isBlocked").notNull().default(false), - hiredAt: timestamp("hiredAt"), - firedAt: timestamp("firedAt"), - onlineAt: timestamp("onlineAt"), - createdAt: timestamp("createdAt").notNull().defaultNow(), - updatedAt: timestamp("updatedAt").notNull().defaultNow(), + 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( - "workersToRestaurants", + "workers_to_restaurants", { - workerId: uuid("workerId").notNull(), - restaurantId: uuid("restaurantId").notNull(), + workerId: uuid("worker_id").notNull(), + restaurantId: uuid("restaurant_id").notNull(), }, (t) => [ primaryKey({ diff --git a/src/dishes-menus/dishes-menus.service.ts b/src/dishes-menus/dishes-menus.service.ts index f404709..5f539d4 100644 --- a/src/dishes-menus/dishes-menus.service.ts +++ b/src/dishes-menus/dishes-menus.service.ts @@ -25,6 +25,7 @@ export class DishesMenusService { ) {} private async onApplicationBootstrap() { + // TODO: replace to CRON await this.dishesMenusProducer.createOwnersDefaultMenu(); } From e0c4d161a972479501f8525c86d0bd8da3d9c0a6 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Mar 2025 14:26:27 +0200 Subject: [PATCH 151/180] feat: assign dish categories to the menus --- src/@base/drizzle/drizzle.module.ts | 5 ++- src/@base/drizzle/schema/dish-categories.ts | 20 +++++++--- src/@base/drizzle/schema/dishes-menus.ts | 2 + src/@core/decorators/sorting.decorator.ts | 3 +- src/@core/utils/camel-to-snake.ts | 6 +++ .../dish-categories.service.ts | 39 +++++++++++-------- .../dtos/create-dish-category.dto.ts | 2 +- .../entities/dish-category.entity.ts | 8 ++++ src/dishes/@/dishes.controller.ts | 6 ++- src/dishes/@/dishes.service.ts | 18 +++++++-- 10 files changed, 79 insertions(+), 30 deletions(-) create mode 100644 src/@core/utils/camel-to-snake.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index ebf07cf..dda5bdd 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -72,7 +72,10 @@ export interface PgTransactionConfig { ssl: env.NODE_ENV === "production" ? true : false, }); - return drizzle(pool, { schema }) as NodePgDatabase; + return drizzle(pool, { + schema, + casing: "snake_case", + }) as NodePgDatabase; }, }, ], diff --git a/src/@base/drizzle/schema/dish-categories.ts b/src/@base/drizzle/schema/dish-categories.ts index f21bf63..af6e163 100644 --- a/src/@base/drizzle/schema/dish-categories.ts +++ b/src/@base/drizzle/schema/dish-categories.ts @@ -1,9 +1,10 @@ +import { dishesMenus } from "@postgress-db/schema/dishes-menus"; import { dishesToCategories } from "@postgress-db/schema/many-to-many"; import { relations } from "drizzle-orm"; import { boolean, - integer, pgTable, + serial, text, timestamp, uuid, @@ -13,7 +14,7 @@ export const dishCategories = pgTable("dish_categories", { id: uuid("id").defaultRandom().primaryKey(), // Category belongs to a menu // - // menuId: uuid("menu_id").notNull(), + menuId: uuid("menu_id").notNull(), // Name of the category // name: text("name").notNull().default(""), @@ -25,7 +26,7 @@ export const dishCategories = pgTable("dish_categories", { showForGuests: boolean("show_for_guests").notNull().default(false), // Sorting index in the admin menu // - sortIndex: integer("sort_index").notNull(), + sortIndex: serial("sort_index").notNull(), // Default timestamps // createdAt: timestamp("created_at").notNull().defaultNow(), @@ -34,6 +35,13 @@ export const dishCategories = pgTable("dish_categories", { export type IDishCategory = typeof dishCategories.$inferSelect; -export const dishCategoryRelations = relations(dishCategories, ({ many }) => ({ - dishesToCategories: many(dishesToCategories), -})); +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/dishes-menus.ts b/src/@base/drizzle/schema/dishes-menus.ts index 52c3cec..72a9064 100644 --- a/src/@base/drizzle/schema/dishes-menus.ts +++ b/src/@base/drizzle/schema/dishes-menus.ts @@ -1,3 +1,4 @@ +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"; @@ -61,6 +62,7 @@ export const dishesMenusToRestaurantsRelations = relations( 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/@core/decorators/sorting.decorator.ts b/src/@core/decorators/sorting.decorator.ts index 2664f9b..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"; @@ -58,7 +59,7 @@ export const SortingParams = createParamDecorator( } return { - sortBy, + sortBy: camelToSnakeCase(sortBy), sortOrder, }; }, 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/dish-categories/dish-categories.service.ts b/src/dish-categories/dish-categories.service.ts index 6208f27..6ca1d4b 100644 --- a/src/dish-categories/dish-categories.service.ts +++ b/src/dish-categories/dish-categories.service.ts @@ -9,7 +9,7 @@ import { ServerErrorException } from "@core/errors/exceptions/server-error.excep 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 { and, asc, count, desc, eq, sql, SQL } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; @@ -46,30 +46,37 @@ export class DishCategoriesService { }): Promise { const { pagination, sorting, filters } = options ?? {}; - const query = this.pg.select().from(schema.dishCategories); + const conditions: SQL[] = []; if (filters) { - query.where( - DrizzleUtils.buildFilterConditions(schema.dishCategories, filters), - ); - } - - if (sorting) { - query.orderBy( - sorting.sortOrder === "asc" - ? asc(sql.identifier(sorting.sortBy)) - : desc(sql.identifier(sorting.sortBy)), + conditions.push( + DrizzleUtils.buildFilterConditions( + schema.dishCategories, + filters, + ) as SQL, ); } - return await query - .limit(pagination?.size ?? PAGINATION_DEFAULT_LIMIT) - .offset(pagination?.offset ?? 0); + 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(), @@ -80,7 +87,7 @@ export class DishCategoriesService { .insert(schema.dishCategories) .values({ ...dto, - sortIndex: sortIndex[0].value, + sortIndex: sortIndex[0].value + startIndex, }) .returning(); diff --git a/src/dish-categories/dtos/create-dish-category.dto.ts b/src/dish-categories/dtos/create-dish-category.dto.ts index 8fb8d2f..c8f8b9b 100644 --- a/src/dish-categories/dtos/create-dish-category.dto.ts +++ b/src/dish-categories/dtos/create-dish-category.dto.ts @@ -3,7 +3,7 @@ import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; import { DishCategoryEntity } from "../entities/dish-category.entity"; export class CreateDishCategoryDto extends IntersectionType( - PickType(DishCategoryEntity, ["name"]), + PickType(DishCategoryEntity, ["name", "menuId"]), PartialType( PickType(DishCategoryEntity, [ "showForWorkers", diff --git a/src/dish-categories/entities/dish-category.entity.ts b/src/dish-categories/entities/dish-category.entity.ts index c1db14b..f77a4af 100644 --- a/src/dish-categories/entities/dish-category.entity.ts +++ b/src/dish-categories/entities/dish-category.entity.ts @@ -18,6 +18,14 @@ export class DishCategoryEntity implements IDishCategory { }) id: string; + @Expose() + @IsUUID() + @ApiProperty({ + description: "Unique identifier of the menu", + example: "d290f1ee-6c54-4b01-90e6-d701748f0851", + }) + menuId: string; + @Expose() @IsString() @ApiProperty({ diff --git a/src/dishes/@/dishes.controller.ts b/src/dishes/@/dishes.controller.ts index bbab651..8216432 100644 --- a/src/dishes/@/dishes.controller.ts +++ b/src/dishes/@/dishes.controller.ts @@ -84,7 +84,7 @@ export class DishesController { }); } - if (menuId) { + if (menuId && typeof menuId === "string" && menuId !== "undefined") { if (!filters) { filters = { filters: [] }; } @@ -96,7 +96,9 @@ export class DishesController { }); } - const total = await this.dishesService.getTotalCount(filters); + const total = await this.dishesService.getTotalCount({ + filters, + }); const data = await this.dishesService.findMany({ pagination, diff --git a/src/dishes/@/dishes.service.ts b/src/dishes/@/dishes.service.ts index 442cae9..ca5e7e2 100644 --- a/src/dishes/@/dishes.service.ts +++ b/src/dishes/@/dishes.service.ts @@ -9,7 +9,7 @@ import { ServerErrorException } from "@core/errors/exceptions/server-error.excep 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 { and, asc, count, desc, eq, sql, SQL } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; @@ -23,7 +23,13 @@ export class DishesService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, ) {} - public async getTotalCount(filters?: IFilters): Promise { + public async getTotalCount({ + filters, + }: { + filters?: IFilters; + }): Promise { + const conditions: SQL[] = []; + const query = this.pg .select({ value: count(), @@ -31,7 +37,13 @@ export class DishesService { .from(schema.dishes); if (filters) { - query.where(DrizzleUtils.buildFilterConditions(schema.dishes, 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); From 2f0ad89367a66705751ad84310b5d151d35c05d6 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Mar 2025 15:33:41 +0200 Subject: [PATCH 152/180] feat: configure guard for the restaurants update/create --- src/i18n/messages/en/errors.json | 4 +- .../@/controllers/restaurants.controller.ts | 17 +++- .../@/services/restaurants.service.ts | 77 +++++++++++++++++-- 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 5617e6c..0cb52e1 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -43,7 +43,9 @@ "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" + "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" diff --git a/src/restaurants/@/controllers/restaurants.controller.ts b/src/restaurants/@/controllers/restaurants.controller.ts index 490c657..bea3d63 100644 --- a/src/restaurants/@/controllers/restaurants.controller.ts +++ b/src/restaurants/@/controllers/restaurants.controller.ts @@ -5,6 +5,7 @@ import { } from "@core/decorators/pagination.decorator"; import { Serializable } from "@core/decorators/serializable.decorator"; import { Worker } from "@core/decorators/worker.decorator"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.exception"; import { RequestWorker } from "@core/interfaces/request"; import { Body, Delete, Get, Param, Post, Put, Query } from "@nestjs/common"; import { @@ -78,7 +79,6 @@ export class RestaurantsController { }; } - // TODO: configure custom guard for this endpoint @EnableAuditLog() @Post() @Serializable(RestaurantEntity) @@ -90,12 +90,20 @@ export class RestaurantsController { type: RestaurantEntity, }) @ApiForbiddenResponse({ - description: "Action available only for SYSTEM_ADMIN, CHIEF_ADMIN", + 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, }); @@ -149,8 +157,11 @@ export class RestaurantsController { async update( @Param("id") id: string, @Body() dto: UpdateRestaurantDto, + @Worker() worker: RequestWorker, ): Promise { - return await this.restaurantsService.update(id, dto); + return await this.restaurantsService.update(id, dto, { + worker, + }); } @RestaurantGuard({ diff --git a/src/restaurants/@/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts index 52ad33a..0c4cf02 100644 --- a/src/restaurants/@/services/restaurants.service.ts +++ b/src/restaurants/@/services/restaurants.service.ts @@ -170,7 +170,9 @@ export class RestaurantsService { .insert(schema.restaurants) .values({ ...dto, - ...(requestWorker?.role === "OWNER" && { ownerId: requestWorker.id }), + ...(requestWorker?.role === "OWNER" && { + ownerId: requestWorker.id, + }), }) .returning({ id: schema.restaurants.id, @@ -179,6 +181,23 @@ export class RestaurantsService { 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 @@ -187,9 +206,41 @@ export class RestaurantsService { */ public async update( id: string, - dto: UpdateRestaurantDto, + payload: UpdateRestaurantDto, + options: { worker: RequestWorker }, ): Promise { - if (dto.timezone && !this.timezonesService.checkTimezone(dto.timezone)) { + 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", { @@ -198,14 +249,25 @@ export class RestaurantsService { ); } + // If trying to unasign restaurant from owner + if (restaurant.ownerId && ownerId === null) { + await this.validateRestaurantOwnerUnasignment(id); + } + // Disable restaurant if it is closed forever - if (dto.isClosedForever) { - dto.isEnabled = false; + if (rest.isClosedForever) { + rest.isEnabled = false; } await this.pg .update(schema.restaurants) - .set(dto) + .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); @@ -216,8 +278,9 @@ export class RestaurantsService { * @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)); From a650e817d29192d6c9a6b8507bb49d5c25b742c8 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Mar 2025 16:04:05 +0200 Subject: [PATCH 153/180] feat: dishes service checks for the worker role and menu --- src/auth/dto/req/sign-in.dto.ts | 2 +- src/dishes/@/dishes.controller.ts | 56 +++++++-- src/dishes/@/dishes.service.ts | 115 ++++++++++++++++-- src/dishes/@/dtos/create-dish.dto.ts | 19 ++- src/dishes/@/dtos/update-dish.dto.ts | 6 +- src/i18n/messages/en/errors.json | 5 +- .../@/services/restaurants.service.ts | 6 +- src/workers/entities/worker.entity.ts | 2 +- 8 files changed, 185 insertions(+), 26 deletions(-) diff --git a/src/auth/dto/req/sign-in.dto.ts b/src/auth/dto/req/sign-in.dto.ts index c1cc153..32194ec 100644 --- a/src/auth/dto/req/sign-in.dto.ts +++ b/src/auth/dto/req/sign-in.dto.ts @@ -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/dishes/@/dishes.controller.ts b/src/dishes/@/dishes.controller.ts index 8216432..3f9257c 100644 --- a/src/dishes/@/dishes.controller.ts +++ b/src/dishes/@/dishes.controller.ts @@ -11,8 +11,11 @@ import { 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, @@ -118,10 +121,36 @@ export class DishesController { @EnableAuditLog() @Post() @Serializable(DishEntity) - @ApiOperation({ summary: "Creates a new dish" }) - @ApiCreatedResponse({ description: "Dish has been successfully created" }) - async create(@Body() data: CreateDishDto): Promise { - const dish = await this.dishesService.create(data); + @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"); @@ -174,9 +203,14 @@ export class DishesController { @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( @@ -184,10 +218,16 @@ export class DishesController { ); } - const updatedDish = await this.dishesService.update(id, { - ...data, - updatedAt: new Date(), - }); + const updatedDish = await this.dishesService.update( + id, + { + ...data, + updatedAt: new Date(), + }, + { + worker, + }, + ); if (!updatedDish) { throw new NotFoundException("errors.dishes.with-this-id-doesnt-exist"); diff --git a/src/dishes/@/dishes.service.ts b/src/dishes/@/dishes.service.ts index ca5e7e2..1ecad38 100644 --- a/src/dishes/@/dishes.service.ts +++ b/src/dishes/@/dishes.service.ts @@ -5,7 +5,9 @@ import { } 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"; @@ -68,7 +70,7 @@ export class DishesService { ] : undefined; - const query = this.pg.query.dishes.findMany({ + const result = await this.pg.query.dishes.findMany({ where, with: { dishesToImages: { @@ -82,8 +84,6 @@ export class DishesService { offset: pagination?.offset ?? 0, }); - const result = await query; - return result.map((dish) => ({ ...dish, images: dish.dishesToImages @@ -96,15 +96,89 @@ export class DishesService { })); } - public async create(dto: CreateDishDto): Promise { - const dishes = await this.pg + 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(); - const dish = dishes[0]; if (!dish) { throw new ServerErrorException("Failed to create dish"); } @@ -114,17 +188,38 @@ export class DishesService { public async update( id: string, - dto: UpdateDishDto, + payload: UpdateDishDto, + options: { worker: RequestWorker }, ): Promise { - if (Object.keys(dto).length === 0) { + 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( - "You should provide at least one field to update", + "errors.dishes.you-should-provide-at-least-one-field-to-update", ); } await this.pg .update(schema.dishes) - .set(dto) + .set(payload) .where(eq(schema.dishes.id, id)); return this.findById(id); diff --git a/src/dishes/@/dtos/create-dish.dto.ts b/src/dishes/@/dtos/create-dish.dto.ts index ac3b3b8..a119088 100644 --- a/src/dishes/@/dtos/create-dish.dto.ts +++ b/src/dishes/@/dtos/create-dish.dto.ts @@ -1,4 +1,11 @@ -import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; +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"; @@ -14,4 +21,12 @@ export class CreateDishDto extends IntersectionType( "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 index 84f635b..df036b7 100644 --- a/src/dishes/@/dtos/update-dish.dto.ts +++ b/src/dishes/@/dtos/update-dish.dto.ts @@ -1,7 +1,9 @@ -import { PartialType } from "@nestjs/swagger"; +import { OmitType, PartialType } from "@nestjs/swagger"; import { CreateDishDto } from "./create-dish.dto"; -export class UpdateDishDto extends PartialType(CreateDishDto) { +export class UpdateDishDto extends PartialType( + OmitType(CreateDishDto, ["menuId"]), +) { updatedAt?: Date; } diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 0cb52e1..02104e6 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -59,7 +59,10 @@ }, "dishes": { "failed-to-create-dish": "Failed to create dish", - "with-this-id-doesnt-exist": "Dish with this id doesn't exist" + "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", diff --git a/src/restaurants/@/services/restaurants.service.ts b/src/restaurants/@/services/restaurants.service.ts index 0c4cf02..bf0a9a5 100644 --- a/src/restaurants/@/services/restaurants.service.ts +++ b/src/restaurants/@/services/restaurants.service.ts @@ -94,7 +94,11 @@ export class RestaurantsService { } return await this.pg.query.restaurants.findMany({ - ...(conditions.length > 0 ? { where: () => and(...conditions) } : {}), + ...(conditions.length > 0 + ? { + where: () => and(...conditions), + } + : {}), limit: pagination.size, offset: pagination.offset, }); diff --git a/src/workers/entities/worker.entity.ts b/src/workers/entities/worker.entity.ts index 55a8dd9..1d6eb60 100644 --- a/src/workers/entities/worker.entity.ts +++ b/src/workers/entities/worker.entity.ts @@ -67,7 +67,7 @@ export class WorkerEntity implements IWorker { @Expose() @ApiProperty({ description: "Login of the worker", - example: "v.keller", + example: "vi.keller", }) login: string; From c8da9683b80b565c2a1b715bb8f38ca46be5d4d9 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Mar 2025 16:38:00 +0200 Subject: [PATCH 154/180] feat: load only dish menu restaurants for pricelist --- .../pricelist/dish-pricelist.controller.ts | 10 +++++-- .../pricelist/dish-pricelist.service.ts | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/dishes/pricelist/dish-pricelist.controller.ts b/src/dishes/pricelist/dish-pricelist.controller.ts index 8c7d819..85c146f 100644 --- a/src/dishes/pricelist/dish-pricelist.controller.ts +++ b/src/dishes/pricelist/dish-pricelist.controller.ts @@ -2,11 +2,11 @@ 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"; -import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; @Controller("dishes/:id/pricelist", { tags: ["dishes"], @@ -17,7 +17,9 @@ export class DishPricelistController { @EnableAuditLog({ onlyErrors: true }) @Get() @Serializable(DishPricelistItemEntity) - @ApiOperation({ summary: "Get dish pricelist" }) + @ApiOperation({ + summary: "Get dish pricelist", + }) @ApiResponse({ status: 200, description: "Returns dish pricelist items", @@ -30,7 +32,9 @@ export class DishPricelistController { @EnableAuditLog() @Put(":restaurantId") @Serializable(DishPricelistItemEntity) - @ApiOperation({ summary: "Update dish pricelist for restaurant" }) + @ApiOperation({ + summary: "Update dish pricelist for restaurant", + }) @ApiResponse({ status: 200, description: "Returns updated dish pricelist item", diff --git a/src/dishes/pricelist/dish-pricelist.service.ts b/src/dishes/pricelist/dish-pricelist.service.ts index 62dfcfe..bae539f 100644 --- a/src/dishes/pricelist/dish-pricelist.service.ts +++ b/src/dishes/pricelist/dish-pricelist.service.ts @@ -14,8 +14,37 @@ export class DishPricelistService { ) {} 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: { From 143c6a7d96b152a853718bf73e69665a80fb19cd Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 7 Mar 2025 16:45:21 +0200 Subject: [PATCH 155/180] feat: validation of assign dish price for restaurants --- .../pricelist/dish-pricelist.controller.ts | 1 + .../pricelist/dish-pricelist.service.ts | 118 ++++++++++++------ src/i18n/messages/en/errors.json | 4 + 3 files changed, 83 insertions(+), 40 deletions(-) diff --git a/src/dishes/pricelist/dish-pricelist.controller.ts b/src/dishes/pricelist/dish-pricelist.controller.ts index 85c146f..be94e2c 100644 --- a/src/dishes/pricelist/dish-pricelist.controller.ts +++ b/src/dishes/pricelist/dish-pricelist.controller.ts @@ -45,6 +45,7 @@ export class DishPricelistController { @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 index bae539f..ca4b260 100644 --- a/src/dishes/pricelist/dish-pricelist.service.ts +++ b/src/dishes/pricelist/dish-pricelist.service.ts @@ -1,4 +1,5 @@ -import { BadRequestException, Inject, Injectable } from "@nestjs/common"; +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"; @@ -89,6 +90,37 @@ export class DishPricelistService { 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), @@ -101,56 +133,62 @@ export class DishPricelistService { if (invalidWorkshopIds.length > 0) { throw new BadRequestException( - `Workshop IDs ${invalidWorkshopIds.join(", ")} do not belong to restaurant ${restaurantId}`, + "errors.dish-pricelist.provided-workshop-ids-dont-belong-to-restaurant", + { + property: "workshopIds", + }, ); } - // Update or create dish-restaurant relation - await this.pg - .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: { + 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, - updatedAt: new Date(), - }, - }); - - // Delete all existing workshop relations for this dish in this restaurant - await this.pg.delete(schema.dishesToWorkshops).where( - and( - eq(schema.dishesToWorkshops.dishId, dishId), - inArray( - schema.dishesToWorkshops.workshopId, - workshops.map((w) => w.id), - ), - ), - ); + }) + .onConflictDoUpdate({ + target: [ + schema.dishesToRestaurants.dishId, + schema.dishesToRestaurants.restaurantId, + ], + set: { + price: dto.price.toString(), + currency: dto.currency, + isInStopList: dto.isInStoplist, + updatedAt: new Date(), + }, + }); - // Create new workshop relations - if (dto.workshopIds.length > 0) { - await this.pg.insert(schema.dishesToWorkshops).values( - dto.workshopIds.map((workshopId) => ({ - dishId, - workshopId, - })), + // 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/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 02104e6..7a7ce2a 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -68,6 +68,10 @@ "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", From 3cdf2a64eef9d536896398953c7ef81fa27762bd Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Mar 2025 14:47:11 +0200 Subject: [PATCH 156/180] feat: redlock for session jwt update --- src/auth/auth.module.ts | 2 + src/auth/guards/session-auth.guard.ts | 54 +++++++++++++++++++-------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 4919e89..e7dbe57 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import env from "@core/env"; import { Module } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; import { DrizzleModule } from "@postgress-db/drizzle.module"; +import { RedlockModule } from "src/@base/redlock/redlock.module"; import { WorkersModule } from "src/workers/workers.module"; import { AuthController } from "./controllers/auth.controller"; @@ -16,6 +17,7 @@ import { SessionsService } from "./services/sessions.service"; JwtModule.register({ secret: env.JWT.SECRET, }), + RedlockModule, ], providers: [AuthService, SessionsService], controllers: [AuthController], diff --git a/src/auth/guards/session-auth.guard.ts b/src/auth/guards/session-auth.guard.ts index 2f21f9d..0f8ed5b 100644 --- a/src/auth/guards/session-auth.guard.ts +++ b/src/auth/guards/session-auth.guard.ts @@ -10,6 +10,7 @@ import { } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; 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"; @@ -21,6 +22,7 @@ export class SessionAuthGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly sessionsService: SessionsService, + private readonly redlockService: RedlockService, ) {} private getUserIp(req: Request) { @@ -48,7 +50,6 @@ export class SessionAuthGuard implements CanActivate { } private async _handleSession(req: Request, res: Response) { - res; const jwtSign = this.getCookie(req, AUTH_COOKIES.token); if (!jwtSign) throw new UnauthorizedException(); @@ -71,20 +72,43 @@ export class SessionAuthGuard implements CanActivate { this.sessionsService.isSessionRequireRefresh(session); if (isRequireRefresh && !isRefreshDisabled) { - 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", - }); + 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); + } + } } return true; From 3184be6dc0696cc15219e113c2b5fa0a576d1972 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Mar 2025 17:11:24 +0200 Subject: [PATCH 157/180] feat: api endpoint for fetching available dishes for order --- nest-cli.json | 2 +- src/@core/dto/cursor-meta.dto.ts | 37 ++++ src/@core/dto/cursor-pagination-meta.dto.ts | 0 src/@core/dto/cursor-response.entity.ts | 16 ++ src/i18n/messages/en/errors.json | 3 + src/i18n/validators/index.ts | 6 + .../@/entities/order-menu-dish.entity.ts | 42 ++++ .../order-menu-dishes-cursor.entity.ts | 14 ++ src/orders/@/order-menu.controller.ts | 51 +++++ src/orders/@/services/order-menu.service.ts | 199 ++++++++++++++++++ src/orders/orders.module.ts | 4 + 11 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/@core/dto/cursor-meta.dto.ts create mode 100644 src/@core/dto/cursor-pagination-meta.dto.ts create mode 100644 src/@core/dto/cursor-response.entity.ts create mode 100644 src/orders/@/entities/order-menu-dish.entity.ts create mode 100644 src/orders/@/entities/order-menu-dishes-cursor.entity.ts create mode 100644 src/orders/@/order-menu.controller.ts create mode 100644 src/orders/@/services/order-menu.service.ts diff --git a/nest-cli.json b/nest-cli.json index a9a0e67..f480375 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -7,7 +7,7 @@ { "include": "i18n/messages/**/*", "watchAssets": true, - "outDir": "dist" + "outDir": "dist/src" } ], "deleteOutDir": true 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/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 7a7ce2a..4e11cf6 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -120,5 +120,8 @@ "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" } } diff --git a/src/i18n/validators/index.ts b/src/i18n/validators/index.ts index 0e11416..a131d07 100644 --- a/src/i18n/validators/index.ts +++ b/src/i18n/validators/index.ts @@ -9,6 +9,7 @@ import { IsInt as _IsInt, IsISO8601 as _IsISO8601, IsLatitude as _IsLatitude, + IsNotEmpty as _IsNotEmpty, IsNumber as _IsNumber, IsObject as _IsObject, IsOptional as _IsOptional, @@ -43,6 +44,11 @@ const mergeI18nValidation = ( ...validationOptions, }); +export const IsNotEmpty = (validationOptions?: ValidationOptions) => + applyDecorators( + _IsNotEmpty(mergeI18nValidation("isNotEmpty", validationOptions)), + ); + export const IsString = (validationOptions?: ValidationOptions) => applyDecorators( _IsString(mergeI18nValidation("isString", validationOptions)), 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..9de2db4 --- /dev/null +++ b/src/orders/@/entities/order-menu-dish.entity.ts @@ -0,0 +1,42 @@ +import { IsBoolean, IsDecimal, 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"]), + 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/@/order-menu.controller.ts b/src/orders/@/order-menu.controller.ts new file mode 100644 index 0000000..8bb2211 --- /dev/null +++ b/src/orders/@/order-menu.controller.ts @@ -0,0 +1,51 @@ +import { Controller } from "@core/decorators/controller.decorator"; +import { CursorParams, ICursor } from "@core/decorators/cursor.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, + ): Promise { + const data = await this.orderMenuService.getDishes(orderId, { + cursor, + }); + + return { + meta: { + cursorId: cursor.cursorId ?? null, + limit: cursor.limit, + total: data.length, + }, + data, + }; + } + + @EnableAuditLog({ onlyErrors: true }) + @Get("dish-categories") + @ApiOperation({ + summary: + "Gets dish categories that exist for the dishes that can be added to the order", + }) + async getDishCategories(@Param("orderId") orderId: string) {} +} diff --git a/src/orders/@/services/order-menu.service.ts b/src/orders/@/services/order-menu.service.ts new file mode 100644 index 0000000..61b262d --- /dev/null +++ b/src/orders/@/services/order-menu.service.ts @@ -0,0 +1,199 @@ +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 { 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"; +import { schema } from "test/helpers/database"; + +@Injectable() +export class OrderMenuService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} + + public async getDishes( + orderId: string, + opts?: { + cursor: ICursor; + }, + ): Promise { + const { cursor } = 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, + }, + 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 }) => + and( + // 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/orders.module.ts b/src/orders/orders.module.ts index e8d58fc..3e4efc2 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -3,9 +3,11 @@ import { DrizzleModule } from "@postgress-db/drizzle.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 { 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"; @@ -22,12 +24,14 @@ import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.se DispatcherOrdersService, KitchenerOrdersService, OrderDishesService, + OrderMenuService, OrderPricesService, OrderActionsService, KitchenerOrderActionsService, ], controllers: [ OrdersController, + OrderMenuController, OrderDishesController, DispatcherOrdersController, KitchenerOrdersController, From 85045a743606130f0bde329a1492ce0d27edb0a2 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Mar 2025 17:55:31 +0200 Subject: [PATCH 158/180] feat: additional fields for order dish at order menu --- src/orders/@/entities/order-menu-dish.entity.ts | 12 ++++++++++-- src/orders/@/order-menu.controller.ts | 8 -------- src/orders/@/services/order-menu.service.ts | 3 +++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/orders/@/entities/order-menu-dish.entity.ts b/src/orders/@/entities/order-menu-dish.entity.ts index 9de2db4..5f9a9bd 100644 --- a/src/orders/@/entities/order-menu-dish.entity.ts +++ b/src/orders/@/entities/order-menu-dish.entity.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsDecimal, ValidateNested } from "@i18n-class-validator"; +import { IsBoolean, ValidateNested } from "@i18n-class-validator"; import { ApiProperty, ApiPropertyOptional, @@ -11,7 +11,15 @@ 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"]), + PickType(OrderDishEntity, [ + "price", + "quantity", + "modifiers", + "id", + "discountPercent", + "surchargePercent", + "finalPrice", + ]), PickType(OrderEntity, ["currency"]), ) {} diff --git a/src/orders/@/order-menu.controller.ts b/src/orders/@/order-menu.controller.ts index 8bb2211..7fba0f9 100644 --- a/src/orders/@/order-menu.controller.ts +++ b/src/orders/@/order-menu.controller.ts @@ -40,12 +40,4 @@ export class OrderMenuController { data, }; } - - @EnableAuditLog({ onlyErrors: true }) - @Get("dish-categories") - @ApiOperation({ - summary: - "Gets dish categories that exist for the dishes that can be added to the order", - }) - async getDishCategories(@Param("orderId") orderId: string) {} } diff --git a/src/orders/@/services/order-menu.service.ts b/src/orders/@/services/order-menu.service.ts index 61b262d..b8166b2 100644 --- a/src/orders/@/services/order-menu.service.ts +++ b/src/orders/@/services/order-menu.service.ts @@ -46,6 +46,9 @@ export class OrderMenuService { dishId: true, price: true, quantity: true, + discountPercent: true, + surchargePercent: true, + finalPrice: true, }, with: { dishModifiersToOrderDishes: { From fead4ea7dd0b16e898ae9aa7ebd1ce70b94c6fed Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 10 Mar 2025 18:06:36 +0200 Subject: [PATCH 159/180] feat: search param for the order menu dishes --- src/orders/@/order-menu.controller.ts | 3 +++ src/orders/@/services/order-menu.service.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/orders/@/order-menu.controller.ts b/src/orders/@/order-menu.controller.ts index 7fba0f9..be84abf 100644 --- a/src/orders/@/order-menu.controller.ts +++ b/src/orders/@/order-menu.controller.ts @@ -1,5 +1,6 @@ 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"; @@ -26,9 +27,11 @@ export class OrderMenuController { async getDishes( @Param("orderId") orderId: string, @CursorParams() cursor: ICursor, + @SearchParam() search?: string, ): Promise { const data = await this.orderMenuService.getDishes(orderId, { cursor, + search, }); return { diff --git a/src/orders/@/services/order-menu.service.ts b/src/orders/@/services/order-menu.service.ts index b8166b2..779167b 100644 --- a/src/orders/@/services/order-menu.service.ts +++ b/src/orders/@/services/order-menu.service.ts @@ -23,9 +23,10 @@ export class OrderMenuService { orderId: string, opts?: { cursor: ICursor; + search?: string; }, ): Promise { - const { cursor } = opts ?? {}; + const { cursor, search } = opts ?? {}; const order = await this.pg.query.orders.findFirst({ where: (orders, { eq }) => eq(orders.id, orderId), @@ -97,8 +98,12 @@ export class OrderMenuService { } const fetchedDishes = await this.pg.query.dishes.findMany({ - where: (dishes, { and, eq, exists, inArray }) => + 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 From 25c1bf2a6c6f7536c5de0d8360649739bc0d23b4 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 11 Mar 2025 18:08:39 +0200 Subject: [PATCH 160/180] feat: workshifts with payments drizzle schema --- src/@base/drizzle/schema/restaurants.ts | 2 + src/@base/drizzle/schema/workers.ts | 10 ++ .../drizzle/schema/workshift-payments.ts | 46 +++++++++ src/@base/drizzle/schema/workshifts.ts | 94 +++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 src/@base/drizzle/schema/workshift-payments.ts create mode 100644 src/@base/drizzle/schema/workshifts.ts diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 236eb8e..65744ec 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -5,6 +5,7 @@ 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 { workshifts } from "@postgress-db/schema/workshifts"; import { relations } from "drizzle-orm"; import { boolean, @@ -92,6 +93,7 @@ export const restaurantRelations = relations(restaurants, ({ one, many }) => ({ dishModifiers: many(dishModifiers), discountsToRestaurants: many(discountsToRestaurants), dishesMenusToRestaurants: many(dishesMenusToRestaurants), + workshifts: many(workshifts), })); export const restaurantHourRelations = relations( diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 2b64d81..6fd7299 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -1,6 +1,11 @@ import { dishesMenus } from "@postgress-db/schema/dishes-menus"; import { orderDeliveries } from "@postgress-db/schema/order-deliveries"; 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, @@ -91,6 +96,11 @@ export const workerRelations = relations(workers, ({ many }) => ({ deliveries: many(orderDeliveries), ownedRestaurants: many(restaurants), ownedDishesMenus: many(dishesMenus), + workshiftsOpened: many(workshifts), + workshiftsClosed: many(workshifts), + workersToWorkshifts: many(workersToWorkshifts), + workshiftPayments: many(workshiftPayments), + removedWorkshiftPayments: many(workshiftPayments), })); export type IWorker = typeof workers.$inferSelect; diff --git a/src/@base/drizzle/schema/workshift-payments.ts b/src/@base/drizzle/schema/workshift-payments.ts new file mode 100644 index 0000000..a96baea --- /dev/null +++ b/src/@base/drizzle/schema/workshift-payments.ts @@ -0,0 +1,46 @@ +import { workers } from "@postgress-db/schema/workers"; +import { workshifts } from "@postgress-db/schema/workshifts"; +import { relations } from "drizzle-orm"; +import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const workshiftPayments = pgTable("workshift_payments", { + id: uuid("id").defaultRandom().primaryKey(), + + // Note // + note: text("note"), + + // 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 }) => ({ + workshift: one(workshifts, { + fields: [workshiftPayments.workshiftId], + references: [workshifts.id], + }), + worker: one(workers, { + fields: [workshiftPayments.workerId], + references: [workers.id], + }), + removedByWorker: one(workers, { + fields: [workshiftPayments.removedByWorkerId], + references: [workers.id], + }), + }), +); diff --git a/src/@base/drizzle/schema/workshifts.ts b/src/@base/drizzle/schema/workshifts.ts new file mode 100644 index 0000000..910557e --- /dev/null +++ b/src/@base/drizzle/schema/workshifts.ts @@ -0,0 +1,94 @@ +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], + }), + closedByWorker: one(workers, { + fields: [workshifts.closedByWorkerId], + references: [workers.id], + }), + 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], + }), + }), +); From 886e6173578156811536a06c7d387df470acf49a Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 11 Mar 2025 19:22:37 +0200 Subject: [PATCH 161/180] feat: order dish returnments drizzle model --- src/@base/drizzle/drizzle.module.ts | 4 +++ src/@base/drizzle/schema/order-dishes.ts | 36 ++++++++++++++++++++++++ src/@base/drizzle/schema/workers.ts | 2 ++ 3 files changed, 42 insertions(+) diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index dda5bdd..49bffc6 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -23,6 +23,8 @@ 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 workshiftPayments from "./schema/workshift-payments"; +import * as workshifts from "./schema/workshifts"; export const schema = { ...general, @@ -42,6 +44,8 @@ export const schema = { ...dishModifiers, ...discounts, ...dishesMenus, + ...workshifts, + ...workshiftPayments, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/order-dishes.ts b/src/@base/drizzle/schema/order-dishes.ts index d80fa06..b2dfea8 100644 --- a/src/@base/drizzle/schema/order-dishes.ts +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -1,6 +1,7 @@ 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, @@ -87,4 +88,39 @@ export const orderDishRelations = relations(orderDishes, ({ one, many }) => ({ 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(), + + // 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/workers.ts b/src/@base/drizzle/schema/workers.ts index 6fd7299..6b6ebfc 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -1,5 +1,6 @@ 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 { @@ -101,6 +102,7 @@ export const workerRelations = relations(workers, ({ many }) => ({ workersToWorkshifts: many(workersToWorkshifts), workshiftPayments: many(workshiftPayments), removedWorkshiftPayments: many(workshiftPayments), + orderDishesReturnments: many(orderDishesReturnments), })); export type IWorker = typeof workers.$inferSelect; From 4938e265a4cb49f36071e742d4b8a78f20f7bacc Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Mar 2025 12:58:46 +0200 Subject: [PATCH 162/180] feat: implement guard for force-ready endpoint --- .../drizzle/schema/workshift-payments.ts | 14 ++++++- src/i18n/messages/en/errors.json | 3 +- src/orders/@/order-dishes.controller.ts | 1 - .../kitchener-order-actions.service.ts | 37 +++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/@base/drizzle/schema/workshift-payments.ts b/src/@base/drizzle/schema/workshift-payments.ts index a96baea..a8e2b6d 100644 --- a/src/@base/drizzle/schema/workshift-payments.ts +++ b/src/@base/drizzle/schema/workshift-payments.ts @@ -1,13 +1,23 @@ +import { currencyEnum } from "@postgress-db/schema/general"; import { workers } from "@postgress-db/schema/workers"; import { workshifts } from "@postgress-db/schema/workshifts"; import { relations } from "drizzle-orm"; -import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { + boolean, + decimal, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; export const workshiftPayments = pgTable("workshift_payments", { id: uuid("id").defaultRandom().primaryKey(), - // Note // + // Fields // note: text("note"), + amount: decimal("amount", { precision: 10, scale: 2 }).notNull(), + currency: currencyEnum("currency").notNull(), // Workshift ID // workshiftId: uuid("workshift_id").notNull(), diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 4e11cf6..cbd6bf7 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -95,7 +95,8 @@ "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-ready-dish": "You can't update ready dish" + "cant-update-ready-dish": "You can't update ready dish", + "cant-force-ready-dish": "You can't force ready dish" }, "order-dish-modifiers": { "some-dish-modifiers-not-found": "Some dish modifiers not found", diff --git a/src/orders/@/order-dishes.controller.ts b/src/orders/@/order-dishes.controller.ts index ac94f7e..60aa33c 100644 --- a/src/orders/@/order-dishes.controller.ts +++ b/src/orders/@/order-dishes.controller.ts @@ -113,7 +113,6 @@ export class OrderDishesController { @Param("orderDishId") orderDishId: string, @Worker() worker: RequestWorker, ) { - // TODO: restrict access to admins await this.kitchenerOrderActionsService.markDishAsReady(orderDishId, { worker, }); diff --git a/src/orders/kitchener/kitchener-order-actions.service.ts b/src/orders/kitchener/kitchener-order-actions.service.ts index 6b69ee9..b8fb2c0 100644 --- a/src/orders/kitchener/kitchener-order-actions.service.ts +++ b/src/orders/kitchener/kitchener-order-actions.service.ts @@ -1,4 +1,5 @@ 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"; @@ -23,8 +24,44 @@ export class KitchenerOrderActionsService { ) { 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"); } From ea7b2b8263506b25f567829faf23c7e09c667cee Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Mar 2025 13:35:16 +0200 Subject: [PATCH 163/180] refactor: order dishes repository --- .../@/repositories/order-dishes.repository.ts | 120 ++++++++++++++++++ src/orders/@/services/order-dishes.service.ts | 88 ++++++------- src/orders/@queue/orders-queue.processor.ts | 11 -- src/orders/orders.module.ts | 7 +- 4 files changed, 161 insertions(+), 65 deletions(-) create mode 100644 src/orders/@/repositories/order-dishes.repository.ts 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/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index f0f93d5..91b0284 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -3,14 +3,13 @@ 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 { orderDishes } from "@postgress-db/schema/order-dishes"; 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 { OrderPricesService } from "src/orders/@/services/order-prices.service"; +import { OrderDishesRepository } from "src/orders/@/repositories/order-dishes.repository"; import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @Injectable() @@ -18,8 +17,8 @@ export class OrderDishesService { constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly repository: OrderDishesRepository, private readonly ordersProducer: OrdersQueueProducer, - private readonly orderPricesService: OrderPricesService, ) {} private readonly getOrderStatement = this.pg.query.orders @@ -149,27 +148,21 @@ export class OrderDishesService { const price = Number(dish.price); - const orderDish = await this.pg.transaction(async (tx) => { - const [orderDish] = await tx - .insert(orderDishes) - .values({ - orderId, - dishId: payload.dishId, - name: dish.name, - status: "pending", - quantity, - isAdditional, - price: String(price), - finalPrice: String(price), - }) - .returning(); - - await this.orderPricesService.calculateOrderTotals(orderId, { - tx, - }); - - return orderDish; - }); + 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", @@ -206,21 +199,15 @@ export class OrderDishesService { throw new BadRequestException("errors.order-dishes.is-removed"); } - const updatedOrderDish = await this.pg.transaction(async (tx) => { - const [updatedOrderDish] = await tx - .update(orderDishes) - .set({ - quantity, - }) - .where(eq(orderDishes.id, orderDishId)) - .returning(); - - await this.orderPricesService.calculateOrderTotals(orderDish.orderId, { - tx, - }); - - return updatedOrderDish; - }); + const updatedOrderDish = await this.repository.update( + orderDishId, + { + quantity, + }, + { + workerId: opts?.workerId, + }, + ); await this.ordersProducer.dishCrudUpdate({ action: "UPDATE", @@ -239,19 +226,16 @@ export class OrderDishesService { throw new BadRequestException("errors.order-dishes.already-removed"); } - const removedOrderDish = await this.pg.transaction(async (tx) => { - const [removedOrderDish] = await tx - .update(orderDishes) - .set({ isRemoved: true, removedAt: new Date() }) - .where(eq(orderDishes.id, orderDishId)) - .returning(); - - await this.orderPricesService.calculateOrderTotals(orderDish.orderId, { - tx, - }); - - return removedOrderDish; - }); + const removedOrderDish = await this.repository.update( + orderDishId, + { + isRemoved: true, + removedAt: new Date(), + }, + { + workerId: opts?.workerId, + }, + ); await this.ordersProducer.dishCrudUpdate({ action: "DELETE", diff --git a/src/orders/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts index 27d946d..ef5e085 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -90,17 +90,6 @@ export class OrdersQueueProcessor extends WorkerHost { } private async dishCrudUpdate(data: OrderDishCrudUpdateJobDto) { - // make snapshot - await this.snapshotsProducer.create({ - model: "ORDER_DISHES", - action: data.action, - data: plainToClass(OrderDishSnapshotEntity, data.orderDish, { - excludeExtraneousValues: true, - }), - documentId: data.orderDishId, - workerId: data.calledByWorkerId, - }); - // notify users await this.ordersSocketNotifier.handleById(data.orderDish.orderId); } diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index 3e4efc2..603b95b 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -1,10 +1,12 @@ 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 { 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"; @@ -18,8 +20,9 @@ import { KitchenerOrdersController } from "src/orders/kitchener/kitchener-orders import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.service"; @Module({ - imports: [DrizzleModule, GuestsModule, OrdersQueueModule], + imports: [DrizzleModule, GuestsModule, OrdersQueueModule, SnapshotsModule], providers: [ + OrderDishesRepository, OrdersService, DispatcherOrdersService, KitchenerOrdersService, @@ -37,6 +40,6 @@ import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.se KitchenerOrdersController, OrderActionsController, ], - exports: [OrdersService, OrderDishesService], + exports: [OrdersService, OrderDishesService, OrderDishesRepository], }) export class OrdersModule {} From 3e7b3d243345e06645d4a4800a904b9f10920db0 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Mar 2025 13:51:17 +0200 Subject: [PATCH 164/180] refactor: orders repository --- .../@/repositories/orders.repository.ts | 105 ++++++++++++++++++ .../@/services/order-actions.service.ts | 18 +-- src/orders/@/services/order-prices.service.ts | 17 +-- src/orders/@/services/orders.service.ts | 32 +++--- src/orders/@queue/orders-queue.processor.ts | 12 -- src/orders/orders.module.ts | 9 +- 6 files changed, 152 insertions(+), 41 deletions(-) create mode 100644 src/orders/@/repositories/orders.repository.ts 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 index a0244b5..1e4cc23 100644 --- a/src/orders/@/services/order-actions.service.ts +++ b/src/orders/@/services/order-actions.service.ts @@ -4,11 +4,11 @@ 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 { eq, inArray } from "drizzle-orm"; +import { inArray } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; import { OrderAvailableActionsEntity } from "src/orders/@/entities/order-available-actions.entity"; +import { OrdersRepository } from "src/orders/@/repositories/orders.repository"; import { OrdersQueueProducer } from "src/orders/@queue/orders-queue.producer"; @Injectable() @@ -17,6 +17,7 @@ export class OrderActionsService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, private readonly ordersProducer: OrdersQueueProducer, + private readonly repository: OrdersRepository, ) {} public async getAvailableActions( @@ -120,14 +121,17 @@ export class OrderActionsService { ); // Set cooking status for order - await tx - .update(orders) - .set({ + await this.repository.update( + orderId, + { status: "cooking", cookingAt: order && order.cookingAt ? new Date(order.cookingAt) : new Date(), - }) - .where(eq(orders.id, orderId)); + }, + { + workerId: opts?.worker?.id, + }, + ); }); } } diff --git a/src/orders/@/services/order-prices.service.ts b/src/orders/@/services/order-prices.service.ts index b369680..bf393c1 100644 --- a/src/orders/@/services/order-prices.service.ts +++ b/src/orders/@/services/order-prices.service.ts @@ -1,9 +1,8 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; import { DrizzleTransaction, Schema } from "@postgress-db/drizzle.module"; -import { orders } from "@postgress-db/schema/orders"; -import { eq } from "drizzle-orm"; 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 { @@ -12,6 +11,7 @@ export class OrderPricesService { constructor( @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + private readonly repository: OrdersRepository, ) {} public async calculateOrderTotals( @@ -54,14 +54,17 @@ export class OrderPricesService { { subtotal: 0, surchargeAmount: 0, discountAmount: 0, total: 0 }, ); - await tx - .update(orders) - .set({ + await this.repository.update( + orderId, + { subtotal: prices.subtotal.toString(), surchargeAmount: prices.surchargeAmount.toString(), discountAmount: prices.discountAmount.toString(), total: prices.total.toString(), - }) - .where(eq(orders.id, orderId)); + }, + { + tx: opts?.tx, + }, + ); } } diff --git a/src/orders/@/services/orders.service.ts b/src/orders/@/services/orders.service.ts index e9df518..28bc8fe 100644 --- a/src/orders/@/services/orders.service.ts +++ b/src/orders/@/services/orders.service.ts @@ -5,7 +5,7 @@ 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, eq, sql } from "drizzle-orm"; +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"; @@ -13,6 +13,7 @@ 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() @@ -22,6 +23,7 @@ export class OrdersService { private readonly pg: NodePgDatabase, private readonly guestsService: GuestsService, private readonly ordersQueueProducer: OrdersQueueProducer, + private readonly repository: OrdersRepository, ) {} private async generateOrderNumber() { @@ -184,9 +186,8 @@ export class OrdersService { const number = await this.generateOrderNumber(); const guest = await this.guestsService.findByPhoneNumber(guestPhone); - const [createdOrder] = await this.pg - .insert(orders) - .values({ + const createdOrder = await this.repository.create( + { number, tableNumber, type, @@ -203,10 +204,11 @@ export class OrdersService { guestId: guest?.id, guestName: guestName ?? guest?.name, guestPhone, - }) - .returning({ - id: orders.id, - }); + }, + { + workerId: opts?.workerId, + }, + ); const order = await this.findById(createdOrder.id); @@ -263,9 +265,9 @@ export class OrdersService { guestName = guest.name; } - const [updatedOrder] = await this.pg - .update(orders) - .set({ + const updatedOrder = await this.repository.update( + id, + { ...(tableNumber ? { tableNumber } : {}), ...(restaurantId ? { restaurantId } : {}), ...(delayedTo ? { delayedTo: new Date(delayedTo) } : {}), @@ -276,9 +278,11 @@ export class OrdersService { ...(guestsAmount ? { guestsAmount } : {}), ...(type ? { type } : {}), ...(paymentMethodId ? { paymentMethodId } : {}), - }) - .where(eq(orders.id, id)) - .returning({ id: orders.id }); + }, + { + workerId: opts?.workerId, + }, + ); const updatedOrderEntity = await this.findById(updatedOrder.id); diff --git a/src/orders/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts index ef5e085..452a417 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -24,7 +24,6 @@ export class OrdersQueueProcessor extends WorkerHost { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, private readonly ordersSocketNotifier: OrdersSocketNotifier, - private readonly snapshotsProducer: SnapshotsProducer, ) { super(); } @@ -74,17 +73,6 @@ export class OrdersQueueProcessor extends WorkerHost { } private async crudUpdate(data: OrderCrudUpdateJobDto) { - // make snapshot - await this.snapshotsProducer.create({ - model: "ORDERS", - action: data.action, - data: plainToClass(OrderSnapshotEntity, data.order, { - excludeExtraneousValues: true, - }), - documentId: data.orderId, - workerId: data.calledByWorkerId, - }); - // notify users await this.ordersSocketNotifier.handle(data.order); } diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts index 603b95b..f14f79a 100644 --- a/src/orders/orders.module.ts +++ b/src/orders/orders.module.ts @@ -7,6 +7,7 @@ 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"; @@ -22,6 +23,7 @@ import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.se @Module({ imports: [DrizzleModule, GuestsModule, OrdersQueueModule, SnapshotsModule], providers: [ + OrdersRepository, OrderDishesRepository, OrdersService, DispatcherOrdersService, @@ -40,6 +42,11 @@ import { KitchenerOrdersService } from "src/orders/kitchener/kitchener-orders.se KitchenerOrdersController, OrderActionsController, ], - exports: [OrdersService, OrderDishesService, OrderDishesRepository], + exports: [ + OrdersService, + OrderDishesService, + OrdersRepository, + OrderDishesRepository, + ], }) export class OrdersModule {} From 04fd501bb6b884d44c5d8186fbd886f08766fc5d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Mar 2025 13:54:28 +0200 Subject: [PATCH 165/180] feat: replace order dishes update methods with repository --- .../@/services/order-actions.service.ts | 3 +++ .../kitchener-order-actions.service.ts | 26 +++++++++---------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/orders/@/services/order-actions.service.ts b/src/orders/@/services/order-actions.service.ts index 1e4cc23..66a6351 100644 --- a/src/orders/@/services/order-actions.service.ts +++ b/src/orders/@/services/order-actions.service.ts @@ -8,6 +8,7 @@ import { inArray } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { PG_CONNECTION } from "src/constants"; 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"; @@ -18,6 +19,7 @@ export class OrderActionsService { private readonly pg: NodePgDatabase, private readonly ordersProducer: OrdersQueueProducer, private readonly repository: OrdersRepository, + private readonly orderDishesRepository: OrderDishesRepository, ) {} public async getAvailableActions( @@ -105,6 +107,7 @@ export class OrderActionsService { 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({ diff --git a/src/orders/kitchener/kitchener-order-actions.service.ts b/src/orders/kitchener/kitchener-order-actions.service.ts index b8fb2c0..99426ad 100644 --- a/src/orders/kitchener/kitchener-order-actions.service.ts +++ b/src/orders/kitchener/kitchener-order-actions.service.ts @@ -4,10 +4,9 @@ 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 } from "@postgress-db/schema/order-dishes"; -import { eq } from "drizzle-orm"; 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() @@ -16,6 +15,7 @@ export class KitchenerOrderActionsService { @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, private readonly ordersProducer: OrdersQueueProducer, + private readonly repository: OrderDishesRepository, ) {} public async markDishAsReady( @@ -72,18 +72,16 @@ export class KitchenerOrderActionsService { ); } - const updatedOrderDish = await this.pg.transaction(async (tx) => { - const [updatedOrderDish] = await tx - .update(orderDishes) - .set({ - status: "ready", - readyAt: new Date(), - }) - .where(eq(orderDishes.id, orderDishId)) - .returning(); - - return updatedOrderDish; - }); + const updatedOrderDish = await this.repository.update( + orderDishId, + { + status: "ready", + readyAt: new Date(), + }, + { + workerId: opts?.worker?.id, + }, + ); await this.ordersProducer.dishCrudUpdate({ action: "UPDATE", From e8c4fc262312784773d8012e794552a763573156 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Mar 2025 15:24:00 +0200 Subject: [PATCH 166/180] feat: order dish return endpoint --- src/@base/drizzle/schema/dishes-menus.ts | 2 +- src/@base/drizzle/schema/order-dishes.ts | 5 + src/@base/drizzle/schema/workers.ts | 16 ++- .../drizzle/schema/workshift-payments.ts | 2 + src/@base/drizzle/schema/workshifts.ts | 2 + src/i18n/messages/en/errors.json | 6 +- .../dtos/create-order-dish-returnment.dto.ts | 7 ++ .../@/entities/order-dish-returnment.ts | 78 ++++++++++++ src/orders/@/order-dishes.controller.ts | 28 +++++ .../@/services/order-actions.service.ts | 116 +++++++++++++++++- 10 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 src/orders/@/dtos/create-order-dish-returnment.dto.ts create mode 100644 src/orders/@/entities/order-dish-returnment.ts diff --git a/src/@base/drizzle/schema/dishes-menus.ts b/src/@base/drizzle/schema/dishes-menus.ts index 72a9064..5940882 100644 --- a/src/@base/drizzle/schema/dishes-menus.ts +++ b/src/@base/drizzle/schema/dishes-menus.ts @@ -33,7 +33,7 @@ export const dishesMenus = pgTable("dishes_menus", { export type IDishesMenu = typeof dishesMenus.$inferSelect; export const dishesMenusToRestaurants = pgTable( - "dishesMenusToRestaurants", + "dishes_menus_to_restaurants", { restaurantId: uuid("restaurant_id").notNull(), dishesMenuId: uuid("dishes_menu_id").notNull(), diff --git a/src/@base/drizzle/schema/order-dishes.ts b/src/@base/drizzle/schema/order-dishes.ts index b2dfea8..3076d68 100644 --- a/src/@base/drizzle/schema/order-dishes.ts +++ b/src/@base/drizzle/schema/order-dishes.ts @@ -104,6 +104,11 @@ export const orderDishesReturnments = pgTable("order_dishes_returnments", { // 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(), diff --git a/src/@base/drizzle/schema/workers.ts b/src/@base/drizzle/schema/workers.ts index 6b6ebfc..a0effa4 100644 --- a/src/@base/drizzle/schema/workers.ts +++ b/src/@base/drizzle/schema/workers.ts @@ -97,11 +97,19 @@ export const workerRelations = relations(workers, ({ many }) => ({ deliveries: many(orderDeliveries), ownedRestaurants: many(restaurants), ownedDishesMenus: many(dishesMenus), - workshiftsOpened: many(workshifts), - workshiftsClosed: many(workshifts), + workshiftsOpened: many(workshifts, { + relationName: "workshiftsOpened", + }), + workshiftsClosed: many(workshifts, { + relationName: "workshiftsClosed", + }), workersToWorkshifts: many(workersToWorkshifts), - workshiftPayments: many(workshiftPayments), - removedWorkshiftPayments: many(workshiftPayments), + workshiftPayments: many(workshiftPayments, { + relationName: "workshiftPaymentsWorker", + }), + removedWorkshiftPayments: many(workshiftPayments, { + relationName: "workshiftPaymentsRemovedByWorker", + }), orderDishesReturnments: many(orderDishesReturnments), })); diff --git a/src/@base/drizzle/schema/workshift-payments.ts b/src/@base/drizzle/schema/workshift-payments.ts index a8e2b6d..3e0094b 100644 --- a/src/@base/drizzle/schema/workshift-payments.ts +++ b/src/@base/drizzle/schema/workshift-payments.ts @@ -47,10 +47,12 @@ export const workshiftPaymentRelations = relations( 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 index 910557e..f48e2ef 100644 --- a/src/@base/drizzle/schema/workshifts.ts +++ b/src/@base/drizzle/schema/workshifts.ts @@ -55,10 +55,12 @@ export const workshiftsRelations = relations(workshifts, ({ one, many }) => ({ 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), diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index cbd6bf7..ad46368 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -96,7 +96,11 @@ "already-removed": "Dish already removed", "cant-force-not-cooking-dish": "You can't force ready not cooking dish", "cant-update-ready-dish": "You can't update ready dish", - "cant-force-ready-dish": "You can't force 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", 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/@/entities/order-dish-returnment.ts b/src/orders/@/entities/order-dish-returnment.ts new file mode 100644 index 0000000..4ee3726 --- /dev/null +++ b/src/orders/@/entities/order-dish-returnment.ts @@ -0,0 +1,78 @@ +import { + IsBoolean, + IsDate, + IsInt, + IsString, + IsUUID, + Min, +} 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() + @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/@/order-dishes.controller.ts b/src/orders/@/order-dishes.controller.ts index 60aa33c..62bde63 100644 --- a/src/orders/@/order-dishes.controller.ts +++ b/src/orders/@/order-dishes.controller.ts @@ -11,9 +11,11 @@ import { } 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"; @@ -26,6 +28,7 @@ export class OrderDishesController { private readonly ordersService: OrdersService, private readonly orderDishesService: OrderDishesService, private readonly kitchenerOrderActionsService: KitchenerOrderActionsService, + private readonly orderActionsService: OrderActionsService, ) {} @EnableAuditLog() @@ -120,6 +123,31 @@ export class OrderDishesController { 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" }) diff --git a/src/orders/@/services/order-actions.service.ts b/src/orders/@/services/order-actions.service.ts index 66a6351..69a1e3f 100644 --- a/src/orders/@/services/order-actions.service.ts +++ b/src/orders/@/services/order-actions.service.ts @@ -1,12 +1,17 @@ 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 } from "@postgress-db/schema/order-dishes"; +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"; @@ -137,4 +142,113 @@ export class OrderActionsService { ); }); } + + 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, + }); + }); + } } From 6fa93634ebd1dc1540f50a0c12f967d6d12f5b9e Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Mar 2025 15:26:17 +0200 Subject: [PATCH 167/180] feat: update modifiers only of pending order dishes --- src/i18n/messages/en/errors.json | 1 + src/orders/@/services/order-dishes.service.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index ad46368..044aa0a 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -95,6 +95,7 @@ "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", diff --git a/src/orders/@/services/order-dishes.service.ts b/src/orders/@/services/order-dishes.service.ts index 91b0284..38566e9 100644 --- a/src/orders/@/services/order-dishes.service.ts +++ b/src/orders/@/services/order-dishes.service.ts @@ -257,7 +257,13 @@ export class OrderDishesService { throw new BadRequestException("errors.order-dishes.is-removed"); } - if (orderDish.status !== "pending" && orderDish.status !== "cooking") { + 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", ); From 23fa91cc9b35293d1353b0523da9744bdb0b0f7b Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Mar 2025 16:06:06 +0200 Subject: [PATCH 168/180] feat: min length for dish returnment --- src/orders/@/entities/order-dish-returnment.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/orders/@/entities/order-dish-returnment.ts b/src/orders/@/entities/order-dish-returnment.ts index 4ee3726..4575033 100644 --- a/src/orders/@/entities/order-dish-returnment.ts +++ b/src/orders/@/entities/order-dish-returnment.ts @@ -5,6 +5,7 @@ import { IsString, IsUUID, Min, + MinLength, } from "@i18n-class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { IOrderDishReturnment } from "@postgress-db/schema/order-dishes"; @@ -45,6 +46,7 @@ export class OrderDishReturnmentEntity implements IOrderDishReturnment { quantity: number; @IsString() + @MinLength(3) @Expose() @ApiProperty({ description: "Reason for the returnment", From 4975afb4d4bf417661856e102de01c9bdc0ed82f Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Wed, 12 Mar 2025 17:24:14 +0200 Subject: [PATCH 169/180] feat: workshifts module with controller and findMany method --- src/app.module.ts | 2 + src/workshifts/entity/workshift.entity.ts | 126 ++++++++++++++++++ .../entity/workshifts-paginated.entity.ts | 15 +++ src/workshifts/services/workshifts.service.ts | 108 +++++++++++++++ src/workshifts/workshifts.controller.ts | 48 +++++++ src/workshifts/workshifts.module.ts | 13 ++ 6 files changed, 312 insertions(+) create mode 100644 src/workshifts/entity/workshift.entity.ts create mode 100644 src/workshifts/entity/workshifts-paginated.entity.ts create mode 100644 src/workshifts/services/workshifts.service.ts create mode 100644 src/workshifts/workshifts.controller.ts create mode 100644 src/workshifts/workshifts.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index e7dd10e..cc12d17 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -35,6 +35,7 @@ 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"; @@ -104,6 +105,7 @@ import { WorkersModule } from "./workers/workers.module"; SocketModule, PaymentMethodsModule, DiscountsModule, + WorkshiftsModule, ], providers: [ { diff --git a/src/workshifts/entity/workshift.entity.ts b/src/workshifts/entity/workshift.entity.ts new file mode 100644 index 0000000..693521b --- /dev/null +++ b/src/workshifts/entity/workshift.entity.ts @@ -0,0 +1,126 @@ +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 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(() => WorkerEntity) + @ApiPropertyOptional({ + description: "Worker who opened the workshift", + type: WorkerEntity, + }) + openedByWorker?: WorkerEntity; + + @Expose() + @Type(() => WorkerEntity) + @ApiPropertyOptional({ + description: "Worker who closed the workshift", + type: WorkerEntity, + }) + closedByWorker?: WorkerEntity; + + @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..14c6467 --- /dev/null +++ b/src/workshifts/services/workshifts.service.ts @@ -0,0 +1,108 @@ +import { + IPagination, + PAGINATION_DEFAULT_LIMIT, +} from "@core/decorators/pagination.decorator"; +import { ForbiddenException } from "@core/errors/exceptions/forbidden.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, inArray, SQL } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { PG_CONNECTION } from "src/constants"; +import { WorkshiftEntity } from "src/workshifts/entity/workshift.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; + }): Promise { + const { worker } = options; + + const conditions: SQL[] = [...this._buildWorkerWhere(worker)]; + + 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 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; + }): Promise { + const { worker, pagination } = options; + + const conditions: SQL[] = [ + // Worker + ...this._buildWorkerWhere(worker), + ]; + + const result = await this.pg.query.workshifts.findMany({ + where: (_, { and }) => and(...conditions), + with: { + restaurant: { + columns: { + id: true, + name: true, + }, + }, + }, + orderBy: [desc(workshifts.createdAt)], + limit: pagination?.size ?? PAGINATION_DEFAULT_LIMIT, + offset: pagination?.offset ?? 0, + }); + + return result; + } +} diff --git a/src/workshifts/workshifts.controller.ts b/src/workshifts/workshifts.controller.ts new file mode 100644 index 0000000..f3335f3 --- /dev/null +++ b/src/workshifts/workshifts.controller.ts @@ -0,0 +1,48 @@ +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 { RequestWorker } from "@core/interfaces/request"; +import { Get } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; +import { WorkshiftsPaginatedEntity } from "src/workshifts/entity/workshifts-paginated.entity"; +import { WorkshiftsService } from "src/workshifts/services/workshifts.service"; + +@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, + }) + async getWorkshifts( + @PaginationParams() pagination: IPagination, + @Worker() worker: RequestWorker, + ): Promise { + const total = await this.workshiftsService.getTotalCount({ + worker, + }); + + const data = await this.workshiftsService.findMany({ + worker, + pagination, + }); + + return { + data, + meta: { + ...pagination, + total, + }, + }; + } +} 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 {} From f0ddf9b65a2eda512fcefa3e21387fb1b5067173 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 13 Mar 2025 11:26:42 +0200 Subject: [PATCH 170/180] feat: open workshift endpoint --- src/i18n/messages/en/errors.json | 5 + src/workshifts/dto/create-workshift.dto.ts | 6 + src/workshifts/services/workshifts.service.ts | 118 +++++++++++++++++- src/workshifts/workshifts.controller.ts | 21 +++- 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 src/workshifts/dto/create-workshift.dto.ts diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 044aa0a..0853eff 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -129,5 +129,10 @@ }, "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" } } 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/services/workshifts.service.ts b/src/workshifts/services/workshifts.service.ts index 14c6467..8c5f5b5 100644 --- a/src/workshifts/services/workshifts.service.ts +++ b/src/workshifts/services/workshifts.service.ts @@ -2,14 +2,17 @@ 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, inArray, SQL } from "drizzle-orm"; +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"; @Injectable() @@ -70,6 +73,42 @@ export class WorkshiftsService { 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, + }, + }, + }, + }); + + return result ?? null; + } + /** * Finds all workshifts * @param options - Options for finding workshifts @@ -105,4 +144,81 @@ export class WorkshiftsService { return result; } + + /** + * 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"); + } + + 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"); + } + } + + 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, + }) + .returning({ + id: workshifts.id, + }); + + return (await this.findOne(createdWorkshift.id, { + worker, + })) as WorkshiftEntity; + } } diff --git a/src/workshifts/workshifts.controller.ts b/src/workshifts/workshifts.controller.ts index f3335f3..44f595c 100644 --- a/src/workshifts/workshifts.controller.ts +++ b/src/workshifts/workshifts.controller.ts @@ -6,9 +6,11 @@ import { import { Serializable } from "@core/decorators/serializable.decorator"; import { Worker } from "@core/decorators/worker.decorator"; import { RequestWorker } from "@core/interfaces/request"; -import { Get } from "@nestjs/common"; +import { Body, Get, Post } from "@nestjs/common"; import { ApiOkResponse, ApiOperation } 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"; @@ -45,4 +47,21 @@ export class WorkshiftsController { }, }; } + + @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, + }); + } } From 1dbe015fcac0a09523a8e7282479338baed727fa Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 13 Mar 2025 11:42:20 +0200 Subject: [PATCH 171/180] feat: close workshifts endpoint --- src/i18n/messages/en/errors.json | 3 +- src/workshifts/entity/workshift.entity.ts | 18 ++- src/workshifts/services/workshifts.service.ts | 114 +++++++++++++++--- src/workshifts/workshifts.controller.ts | 19 ++- 4 files changed, 130 insertions(+), 24 deletions(-) diff --git a/src/i18n/messages/en/errors.json b/src/i18n/messages/en/errors.json index 0853eff..e3da2b4 100644 --- a/src/i18n/messages/en/errors.json +++ b/src/i18n/messages/en/errors.json @@ -133,6 +133,7 @@ "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" + "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/workshifts/entity/workshift.entity.ts b/src/workshifts/entity/workshift.entity.ts index 693521b..04ccdff 100644 --- a/src/workshifts/entity/workshift.entity.ts +++ b/src/workshifts/entity/workshift.entity.ts @@ -13,6 +13,12 @@ export class WorkshiftRestaurantEntity extends PickType(RestaurantEntity, [ "name", ]) {} +export class WorkshiftWorkerEntity extends PickType(WorkerEntity, [ + "id", + "name", + "role", +]) {} + export class WorkshiftEntity implements IWorkshift { @IsUUID() @Expose() @@ -101,20 +107,20 @@ export class WorkshiftEntity implements IWorkshift { closedAt: Date | null; @Expose() - @Type(() => WorkerEntity) + @Type(() => WorkshiftWorkerEntity) @ApiPropertyOptional({ description: "Worker who opened the workshift", - type: WorkerEntity, + type: WorkshiftWorkerEntity, }) - openedByWorker?: WorkerEntity; + openedByWorker?: WorkshiftWorkerEntity | null; @Expose() - @Type(() => WorkerEntity) + @Type(() => WorkshiftWorkerEntity) @ApiPropertyOptional({ description: "Worker who closed the workshift", - type: WorkerEntity, + type: WorkshiftWorkerEntity, }) - closedByWorker?: WorkerEntity; + closedByWorker?: WorkshiftWorkerEntity | null; @Expose() @Type(() => WorkerEntity) diff --git a/src/workshifts/services/workshifts.service.ts b/src/workshifts/services/workshifts.service.ts index 8c5f5b5..4a5b89c 100644 --- a/src/workshifts/services/workshifts.service.ts +++ b/src/workshifts/services/workshifts.service.ts @@ -103,6 +103,20 @@ export class WorkshiftsService { name: true, }, }, + openedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + closedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, }, }); @@ -136,6 +150,20 @@ export class WorkshiftsService { name: true, }, }, + openedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, + closedByWorker: { + columns: { + id: true, + name: true, + role: true, + }, + }, }, orderBy: [desc(workshifts.createdAt)], limit: pagination?.size ?? PAGINATION_DEFAULT_LIMIT, @@ -145,6 +173,32 @@ export class WorkshiftsService { 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 @@ -173,22 +227,8 @@ export class WorkshiftsService { throw new NotFoundException("errors.workshifts.restaurant-not-available"); } - 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"); - } - } + // 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)), @@ -212,6 +252,7 @@ export class WorkshiftsService { status: "OPENED", restaurantId, openedByWorkerId: worker.id, + openedAt: new Date(), }) .returning({ id: workshifts.id, @@ -221,4 +262,45 @@ export class WorkshiftsService { 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; + } } diff --git a/src/workshifts/workshifts.controller.ts b/src/workshifts/workshifts.controller.ts index 44f595c..cb17b40 100644 --- a/src/workshifts/workshifts.controller.ts +++ b/src/workshifts/workshifts.controller.ts @@ -6,7 +6,7 @@ import { import { Serializable } from "@core/decorators/serializable.decorator"; import { Worker } from "@core/decorators/worker.decorator"; import { RequestWorker } from "@core/interfaces/request"; -import { Body, Get, Post } from "@nestjs/common"; +import { Body, Get, Param, Post } from "@nestjs/common"; import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; import { EnableAuditLog } from "src/@base/audit-logs/decorators/audit-logs.decorator"; import { CreateWorkshiftDto } from "src/workshifts/dto/create-workshift.dto"; @@ -64,4 +64,21 @@ export class WorkshiftsController { 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, + }); + } } From 540eb5a5589e44b5348f19984733b1cf82637389 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 13 Mar 2025 13:33:20 +0200 Subject: [PATCH 172/180] feat: restaurantId filter for findMany workshifts --- src/workshifts/services/workshifts.service.ts | 14 ++++++++++++-- src/workshifts/workshifts.controller.ts | 17 +++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/workshifts/services/workshifts.service.ts b/src/workshifts/services/workshifts.service.ts index 4a5b89c..e037058 100644 --- a/src/workshifts/services/workshifts.service.ts +++ b/src/workshifts/services/workshifts.service.ts @@ -55,11 +55,16 @@ export class WorkshiftsService { public async getTotalCount(options: { worker: RequestWorker; + restaurantId?: string; }): Promise { - const { worker } = options; + 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(), @@ -133,14 +138,19 @@ export class WorkshiftsService { public async findMany(options: { worker: RequestWorker; pagination?: IPagination; + restaurantId?: string; }): Promise { - const { worker, pagination } = options; + 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: { diff --git a/src/workshifts/workshifts.controller.ts b/src/workshifts/workshifts.controller.ts index cb17b40..4698d02 100644 --- a/src/workshifts/workshifts.controller.ts +++ b/src/workshifts/workshifts.controller.ts @@ -6,8 +6,8 @@ import { import { Serializable } from "@core/decorators/serializable.decorator"; import { Worker } from "@core/decorators/worker.decorator"; import { RequestWorker } from "@core/interfaces/request"; -import { Body, Get, Param, Post } from "@nestjs/common"; -import { ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +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"; @@ -26,17 +26,30 @@ export class WorkshiftsController { 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 { From eee0275217bb0349f83150b90feafe71a63872de Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 13 Mar 2025 15:50:48 +0200 Subject: [PATCH 173/180] feat: navigation endpoint for workshifts --- .../entity/workshift-navigation.entity.ts | 23 +++++++ src/workshifts/services/workshifts.service.ts | 66 ++++++++++++++++++- src/workshifts/workshifts.controller.ts | 18 +++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/workshifts/entity/workshift-navigation.entity.ts 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/services/workshifts.service.ts b/src/workshifts/services/workshifts.service.ts index e037058..ea38a91 100644 --- a/src/workshifts/services/workshifts.service.ts +++ b/src/workshifts/services/workshifts.service.ts @@ -15,6 +15,8 @@ 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( @@ -175,7 +177,7 @@ export class WorkshiftsService { }, }, }, - orderBy: [desc(workshifts.createdAt)], + orderBy: [desc(workshifts.createdAt), desc(workshifts.id)], limit: pagination?.size ?? PAGINATION_DEFAULT_LIMIT, offset: pagination?.offset ?? 0, }); @@ -313,4 +315,66 @@ export class WorkshiftsService { 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 index 4698d02..a3ad548 100644 --- a/src/workshifts/workshifts.controller.ts +++ b/src/workshifts/workshifts.controller.ts @@ -14,6 +14,8 @@ 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) {} @@ -94,4 +96,20 @@ export class WorkshiftsController { 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, + }); + } } From de1549b3803a38cde32e6c35a29cce2a51c758ee Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Thu, 13 Mar 2025 19:21:14 +0200 Subject: [PATCH 174/180] feat: workshift payment category drizzle model --- src/@base/drizzle/drizzle.module.ts | 2 + src/@base/drizzle/schema/restaurants.ts | 2 + .../schema/workshift-payment-category.ts | 54 +++++++++++++++++++ .../drizzle/schema/workshift-payments.ts | 14 +++++ 4 files changed, 72 insertions(+) create mode 100644 src/@base/drizzle/schema/workshift-payment-category.ts diff --git a/src/@base/drizzle/drizzle.module.ts b/src/@base/drizzle/drizzle.module.ts index 49bffc6..5d57fe9 100644 --- a/src/@base/drizzle/drizzle.module.ts +++ b/src/@base/drizzle/drizzle.module.ts @@ -23,6 +23,7 @@ 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"; @@ -46,6 +47,7 @@ export const schema = { ...dishesMenus, ...workshifts, ...workshiftPayments, + ...workshiftPaymentCategories, }; export type Schema = typeof schema; diff --git a/src/@base/drizzle/schema/restaurants.ts b/src/@base/drizzle/schema/restaurants.ts index 65744ec..8844972 100644 --- a/src/@base/drizzle/schema/restaurants.ts +++ b/src/@base/drizzle/schema/restaurants.ts @@ -5,6 +5,7 @@ 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 { @@ -94,6 +95,7 @@ export const restaurantRelations = relations(restaurants, ({ one, many }) => ({ discountsToRestaurants: many(discountsToRestaurants), dishesMenusToRestaurants: many(dishesMenusToRestaurants), workshifts: many(workshifts), + workshiftPaymentCategories: many(workshiftPaymentCategories), })); export const restaurantHourRelations = relations( 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..425845a --- /dev/null +++ b/src/@base/drizzle/schema/workshift-payment-category.ts @@ -0,0 +1,54 @@ +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, { + fields: [workshiftPaymentCategories.parentId], + references: [workshiftPaymentCategories.id], + }), + childrens: many(workshiftPaymentCategories), + 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 index 3e0094b..2e6cbb6 100644 --- a/src/@base/drizzle/schema/workshift-payments.ts +++ b/src/@base/drizzle/schema/workshift-payments.ts @@ -1,18 +1,28 @@ 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"; +export const workshiftPaymentTypeEnum = pgEnum("workshift_payment_type", [ + "INCOME", + "EXPENSE", + "CASHLESS", +]); + export const workshiftPayments = pgTable("workshift_payments", { id: uuid("id").defaultRandom().primaryKey(), + categoryId: uuid("category_id").notNull(), + type: workshiftPaymentTypeEnum("type").notNull(), // Fields // note: text("note"), @@ -40,6 +50,10 @@ 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], From 0f7015806cd8c64516554a3fdb090722c4df8f4d Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Fri, 14 Mar 2025 22:44:51 +0200 Subject: [PATCH 175/180] feat: workshift restaurant category entity --- .../drizzle/schema/workshift-payments.ts | 5 + src/restaurants/restaurants.module.ts | 4 + .../workshift-payment-category.entity.ts | 115 ++++++++++++++++++ ...workshift-payment-categories.controller.ts | 23 ++++ ...nt-workshift-payment-categories.service.ts | 11 ++ 5 files changed, 158 insertions(+) create mode 100644 src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity.ts create mode 100644 src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.controller.ts create mode 100644 src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service.ts diff --git a/src/@base/drizzle/schema/workshift-payments.ts b/src/@base/drizzle/schema/workshift-payments.ts index 2e6cbb6..1617ad9 100644 --- a/src/@base/drizzle/schema/workshift-payments.ts +++ b/src/@base/drizzle/schema/workshift-payments.ts @@ -12,6 +12,7 @@ import { timestamp, uuid, } from "drizzle-orm/pg-core"; +import { z } from "zod"; export const workshiftPaymentTypeEnum = pgEnum("workshift_payment_type", [ "INCOME", @@ -19,6 +20,10 @@ export const workshiftPaymentTypeEnum = pgEnum("workshift_payment_type", [ "CASHLESS", ]); +export const ZodWorkshiftPaymentType = z.enum( + workshiftPaymentTypeEnum.enumValues, +); + export const workshiftPayments = pgTable("workshift_payments", { id: uuid("id").defaultRandom().primaryKey(), categoryId: uuid("category_id").notNull(), diff --git a/src/restaurants/restaurants.module.ts b/src/restaurants/restaurants.module.ts index b593d1c..f7e84bc 100644 --- a/src/restaurants/restaurants.module.ts +++ b/src/restaurants/restaurants.module.ts @@ -1,5 +1,7 @@ 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 { RestaurantsController } from "./@/controllers/restaurants.controller"; @@ -18,12 +20,14 @@ import { RestaurantWorkshopsService } from "./workshops/restaurant-workshops.ser RestaurantHoursService, RestaurantWorkshopsService, RestaurantDishModifiersService, + RestaurantWorkshiftPaymentCategoriesService, ], controllers: [ RestaurantsController, RestaurantHoursController, RestaurantWorkshopsController, RestaurantDishModifiersController, + RestaurantWorkshiftPaymentCategoriesController, ], exports: [ RestaurantsService, 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..d11bbf0 --- /dev/null +++ b/src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity.ts @@ -0,0 +1,115 @@ +import { + 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 } 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; + + @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..b4198fd --- /dev/null +++ b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.controller.ts @@ -0,0 +1,23 @@ +import { Controller } from "@core/decorators/controller.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 { 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 }) + @Get() + @ApiOperation({ summary: "Gets restaurant workshift payment categories" }) + @ApiOkResponse({ + description: + "Restaurant workshift payment categories have been successfully fetched", + }) + async findAll(@Param("restaurantId") restaurantId: string) {} +} 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..7812927 --- /dev/null +++ b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service.ts @@ -0,0 +1,11 @@ +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 RestaurantWorkshiftPaymentCategoriesService { + constructor( + @Inject(PG_CONNECTION) private readonly pg: NodePgDatabase, + ) {} +} From b50d755bb7e5fe9aa72e984bb08b4ed8493ebdc3 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Mar 2025 14:55:43 +0200 Subject: [PATCH 176/180] feat: crud for workshift payment categories of restaurants --- .../schema/workshift-payment-category.ts | 5 +- .../create-workshift-payment-category.dto.ts | 7 + .../update-workshift-payment-category.dto.ts | 6 + .../workshift-payment-category.entity.ts | 12 +- ...workshift-payment-categories.controller.ts | 78 ++++++++++- ...nt-workshift-payment-categories.service.ts | 128 ++++++++++++++++++ 6 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 src/restaurants/workshift-payment-categories/dto/create-workshift-payment-category.dto.ts create mode 100644 src/restaurants/workshift-payment-categories/dto/update-workshift-payment-category.dto.ts diff --git a/src/@base/drizzle/schema/workshift-payment-category.ts b/src/@base/drizzle/schema/workshift-payment-category.ts index 425845a..6159617 100644 --- a/src/@base/drizzle/schema/workshift-payment-category.ts +++ b/src/@base/drizzle/schema/workshift-payment-category.ts @@ -41,10 +41,13 @@ export const workshiftPaymentCategoryRelations = relations( workshiftPaymentCategories, ({ one, many }) => ({ parent: one(workshiftPaymentCategories, { + relationName: "parentToChild", fields: [workshiftPaymentCategories.parentId], references: [workshiftPaymentCategories.id], }), - childrens: many(workshiftPaymentCategories), + childrens: many(workshiftPaymentCategories, { + relationName: "parentToChild", + }), restaurant: one(restaurants, { fields: [workshiftPaymentCategories.restaurantId], references: [restaurants.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..ae1551f --- /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"]), +) {} 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 index d11bbf0..93c0e56 100644 --- a/src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity.ts +++ b/src/restaurants/workshift-payment-categories/entity/workshift-payment-category.entity.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsISO8601, IsOptional, @@ -7,7 +8,7 @@ import { } from "@i18n-class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ZodWorkshiftPaymentType } from "@postgress-db/schema/workshift-payments"; -import { Expose } from "class-transformer"; +import { Expose, Type } from "class-transformer"; export class WorkshiftPaymentCategoryEntity { @IsUUID() @@ -85,6 +86,15 @@ export class WorkshiftPaymentCategoryEntity { }) isRemoved: boolean; + @IsArray() + @Expose() + @Type(() => WorkshiftPaymentCategoryEntity) + @ApiProperty({ + description: "Children categories", + type: [WorkshiftPaymentCategoryEntity], + }) + childrens: WorkshiftPaymentCategoryEntity[]; + @IsISO8601() @Expose() @ApiProperty({ 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 index b4198fd..724be3a 100644 --- a/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.controller.ts +++ b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.controller.ts @@ -1,7 +1,13 @@ import { Controller } from "@core/decorators/controller.decorator"; -import { Get, Param } from "@nestjs/common"; +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", { @@ -13,11 +19,79 @@ export class RestaurantWorkshiftPaymentCategoriesController { ) {} @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) {} + 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 index 7812927..8ae1927 100644 --- a/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service.ts +++ b/src/restaurants/workshift-payment-categories/restaurant-workshift-payment-categories.service.ts @@ -1,11 +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; + } } From 60c56a245956c92f5bba2005b87d28af383af4b6 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Mar 2025 18:40:46 +0200 Subject: [PATCH 177/180] feat: updating is active state --- .../dto/update-workshift-payment-category.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ae1551f..2634d2d 100644 --- 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 @@ -2,5 +2,5 @@ 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"]), + PickType(WorkshiftPaymentCategoryEntity, ["name", "description", "isActive"]), ) {} From 286580e7aa11a3fa265d9492ebc9069be6e80d51 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Mon, 17 Mar 2025 18:55:25 +0200 Subject: [PATCH 178/180] feat: dockerfile --- .dockerignore | 37 +++++++++++++++++ Dockerfile | 45 +++++++++++++++++++++ docker-compose.yml | 13 ++++++ src/orders/@/services/order-menu.service.ts | 2 +- src/orders/@queue/orders-queue.processor.ts | 4 -- 5 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml 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/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/src/orders/@/services/order-menu.service.ts b/src/orders/@/services/order-menu.service.ts index 779167b..d42baf3 100644 --- a/src/orders/@/services/order-menu.service.ts +++ b/src/orders/@/services/order-menu.service.ts @@ -3,6 +3,7 @@ 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"; @@ -11,7 +12,6 @@ import { OrderMenuDishEntity, OrderMenuDishOrderDishEntity, } from "src/orders/@/entities/order-menu-dish.entity"; -import { schema } from "test/helpers/database"; @Injectable() export class OrderMenuService { diff --git a/src/orders/@queue/orders-queue.processor.ts b/src/orders/@queue/orders-queue.processor.ts index 452a417..54d271b 100644 --- a/src/orders/@queue/orders-queue.processor.ts +++ b/src/orders/@queue/orders-queue.processor.ts @@ -2,12 +2,8 @@ import { Processor, WorkerHost } from "@nestjs/bullmq"; import { Inject, Logger } from "@nestjs/common"; import { Schema } from "@postgress-db/drizzle.module"; import { Job } from "bullmq"; -import { plainToClass } from "class-transformer"; 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 { OrderSnapshotEntity } from "src/orders/@/entities/order-snapshot.entity"; import { OrderQueueJobName, ORDERS_QUEUE } from "src/orders/@queue"; import { OrderCrudUpdateJobDto, From 714ac434dc1f0089639c6927a0a6ab1e8ab7d946 Mon Sep 17 00:00:00 2001 From: Yefrosynii Kolenko Date: Tue, 18 Mar 2025 13:55:27 +0200 Subject: [PATCH 179/180] feat: get workshift api endpoint --- nest-cli.json | 2 +- src/workshifts/workshifts.controller.ts | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/nest-cli.json b/nest-cli.json index f480375..a9a0e67 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -7,7 +7,7 @@ { "include": "i18n/messages/**/*", "watchAssets": true, - "outDir": "dist/src" + "outDir": "dist" } ], "deleteOutDir": true diff --git a/src/workshifts/workshifts.controller.ts b/src/workshifts/workshifts.controller.ts index a3ad548..406882d 100644 --- a/src/workshifts/workshifts.controller.ts +++ b/src/workshifts/workshifts.controller.ts @@ -5,6 +5,7 @@ import { } 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"; @@ -63,6 +64,29 @@ export class WorkshiftsController { }; } + @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) From 88c088f13420460b4e54e846f33c75435373792b Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Thu, 20 Mar 2025 08:14:32 +0000 Subject: [PATCH 180/180] fix: upgrade drizzle-zod from 0.6.1 to 0.7.0 Snyk has created this PR to upgrade drizzle-zod from 0.6.1 to 0.7.0. See this package in yarn: drizzle-zod See this project in Snyk: https://app.snyk.io/org/efroostrf/project/f557626c-3308-4e7b-8b7c-b155c0593ac4?utm_source=github&utm_medium=referral&page=upgrade-pr --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 3b57531..fc8731b 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "date-fns-tz": "^3.2.0", "dotenv": "^16.4.7", "drizzle-orm": "0.38.3", - "drizzle-zod": "0.6.1", + "drizzle-zod": "0.7.0", "eslint-plugin-import": "^2.29.1", "ioredis": "^5.4.2", "jsonwebtoken": "^9.0.2", diff --git a/yarn.lock b/yarn.lock index 793dab1..1ae57d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4042,10 +4042,10 @@ drizzle-orm@0.38.3: resolved "https://registry.yarnpkg.com/drizzle-orm/-/drizzle-orm-0.38.3.tgz#2bdf9a649eda9731cfd3f39b2fdaf6bf844be492" integrity sha512-w41Y+PquMpSff/QDRGdItG0/aWca+/J3Sda9PPGkTxBtjWQvgU1jxlFBXdjog5tYvTu58uvi3PwR1NuCx0KeZg== -drizzle-zod@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/drizzle-zod/-/drizzle-zod-0.6.1.tgz#2024a9ece75f27748c7a71ee48a094ee1957fd90" - integrity sha512-huEbUgnsuR8tupnmLiyB2F1I2H9dswI3GfM36IbIqx9i0YUeYjRsDpJVyFVeziUvI1ogT9JHRL2Q03cC4QmvxA== +drizzle-zod@0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/drizzle-zod/-/drizzle-zod-0.7.0.tgz#cffc9cd37adc7a528f30bdf29c1510ba0848e1da" + integrity sha512-xgCRYYVEzRkeXTS33GSMgoowe3vKsMNBjSI+cwG1oLQVEhAWWbqtb/AAMlm7tkmV4fG/uJjEmWzdzlEmTgWOoQ== eastasianwidth@^0.2.0: version "0.2.0"