From 7825acaa97e501e4b102b085ea42bca6460ecdc0 Mon Sep 17 00:00:00 2001 From: DrMesta103 Date: Sun, 29 Mar 2026 16:14:34 +0300 Subject: [PATCH] add redis and prining --- package-lock.json | 79 ++++ package.json | 1 + src/app.module.ts | 8 + src/config/typeorm.config.ts | 2 + .../auth/guards/optional-jwt-auth.guard.ts | 21 + src/modules/cache/redis-cache.module.ts | 11 + src/modules/cache/redis-cache.service.ts | 80 ++++ .../catalog/admin-products.controller.ts | 14 + src/modules/catalog/catalog.module.ts | 2 + .../dto/bulk-update-product-prices.dto.ts | 31 ++ .../catalog/dto/update-product-price.dto.ts | 11 + .../catalog/entities/product.entity.ts | 2 + .../catalog/product-details.controller.ts | 16 +- src/modules/catalog/products.controller.ts | 40 +- src/modules/catalog/products.service.ts | 391 +++++++++++++++--- src/modules/pricing/pricing.module.ts | 11 + src/modules/pricing/pricing.service.ts | 106 +++++ .../dto/update-pricing-settings.dto.ts | 16 + .../settings/entities/settings.entity.ts | 41 ++ .../settings/enums/currency-display.enum.ts | 4 + src/modules/settings/settings.controller.ts | 36 ++ src/modules/settings/settings.module.ts | 14 + src/modules/settings/settings.service.ts | 46 +++ 23 files changed, 916 insertions(+), 67 deletions(-) create mode 100644 src/modules/auth/guards/optional-jwt-auth.guard.ts create mode 100644 src/modules/cache/redis-cache.module.ts create mode 100644 src/modules/cache/redis-cache.service.ts create mode 100644 src/modules/catalog/dto/bulk-update-product-prices.dto.ts create mode 100644 src/modules/catalog/dto/update-product-price.dto.ts create mode 100644 src/modules/pricing/pricing.module.ts create mode 100644 src/modules/pricing/pricing.service.ts create mode 100644 src/modules/settings/dto/update-pricing-settings.dto.ts create mode 100644 src/modules/settings/entities/settings.entity.ts create mode 100644 src/modules/settings/enums/currency-display.enum.ts create mode 100644 src/modules/settings/settings.controller.ts create mode 100644 src/modules/settings/settings.module.ts create mode 100644 src/modules/settings/settings.service.ts diff --git a/package-lock.json b/package-lock.json index e43438e6..cd5d41bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "ioredis": "^5.10.1", "iterare": "1.2.1", "minio": "^8.0.5", "passport": "^0.7.0", @@ -799,6 +800,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://package-mirror.liara.ir/repository/npm/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2913,6 +2920,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://package-mirror.liara.ir/repository/npm/depd/-/depd-2.0.0.tgz", @@ -4147,6 +4163,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.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" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://package-mirror.liara.ir/repository/npm/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -4539,12 +4579,24 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://package-mirror.liara.ir/repository/npm/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://package-mirror.liara.ir/repository/npm/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -5564,6 +5616,27 @@ "node": ">= 18" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://package-mirror.liara.ir/repository/npm/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -6022,6 +6095,12 @@ "node": ">=14" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://package-mirror.liara.ir/repository/npm/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 384041bc..831a4c73 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "ioredis": "^5.10.1", "iterare": "1.2.1", "minio": "^8.0.5", "passport": "^0.7.0", diff --git a/src/app.module.ts b/src/app.module.ts index 5d40a7fa..2e4e65ea 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,9 +16,13 @@ import { ProductAttributeValue } from './modules/catalog/entities/product-attrib import { ProductMeta } from './modules/catalog/entities/product-meta.entity'; import { Product } from './modules/catalog/entities/product.entity'; import { ProductReview } from './modules/catalog/entities/product-review.entity'; +import { RedisCacheModule } from './modules/cache/redis-cache.module'; import { MediaModule } from './modules/media/media.module'; import { MediaAsset } from './modules/media/entities/media-asset.entity'; import { CatalogModule } from './modules/catalog/catalog.module'; +import { PricingModule } from './modules/pricing/pricing.module'; +import { Settings } from './modules/settings/entities/settings.entity'; +import { SettingsModule } from './modules/settings/settings.module'; import { StorageModule } from './modules/storage/storage.module'; import { LoyaltyProfile } from './modules/users/entities/loyalty-profile.entity'; import { User } from './modules/users/entities/user.entity'; @@ -51,10 +55,14 @@ import { UsersModule } from './modules/users/users.module'; ProductMeta, AttributeDefinition, ProductAttributeValue, + Settings, MediaAsset, ]), + RedisCacheModule, StorageModule, UsersModule, + SettingsModule, + PricingModule, CatalogModule, MediaModule, AuthModule, diff --git a/src/config/typeorm.config.ts b/src/config/typeorm.config.ts index 6784d5c1..ddbf4a9c 100644 --- a/src/config/typeorm.config.ts +++ b/src/config/typeorm.config.ts @@ -9,6 +9,7 @@ import { ProductAttributeValue } from '../modules/catalog/entities/product-attri import { ProductMeta } from '../modules/catalog/entities/product-meta.entity'; import { Product } from '../modules/catalog/entities/product.entity'; import { ProductReview } from '../modules/catalog/entities/product-review.entity'; +import { Settings } from '../modules/settings/entities/settings.entity'; import { MediaAsset } from '../modules/media/entities/media-asset.entity'; import { LoyaltyProfile } from '../modules/users/entities/loyalty-profile.entity'; import { User } from '../modules/users/entities/user.entity'; @@ -41,6 +42,7 @@ export const buildTypeOrmOptions = ( ProductMeta, AttributeDefinition, ProductAttributeValue, + Settings, MediaAsset, ], autoLoadEntities: false, diff --git a/src/modules/auth/guards/optional-jwt-auth.guard.ts b/src/modules/auth/guards/optional-jwt-auth.guard.ts new file mode 100644 index 00000000..cd177fce --- /dev/null +++ b/src/modules/auth/guards/optional-jwt-auth.guard.ts @@ -0,0 +1,21 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class OptionalJwtAuthGuard extends AuthGuard('jwt') { + async canActivate(context: ExecutionContext) { + try { + return (await super.canActivate(context)) as boolean; + } catch { + return true; + } + } + + handleRequest(err: unknown, user: TUser) { + if (err) { + return null as TUser; + } + + return (user ?? null) as TUser; + } +} diff --git a/src/modules/cache/redis-cache.module.ts b/src/modules/cache/redis-cache.module.ts new file mode 100644 index 00000000..fee416ad --- /dev/null +++ b/src/modules/cache/redis-cache.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { RedisCacheService } from './redis-cache.service'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [RedisCacheService], + exports: [RedisCacheService], +}) +export class RedisCacheModule {} diff --git a/src/modules/cache/redis-cache.service.ts b/src/modules/cache/redis-cache.service.ts new file mode 100644 index 00000000..c8fc603b --- /dev/null +++ b/src/modules/cache/redis-cache.service.ts @@ -0,0 +1,80 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisCacheService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(RedisCacheService.name); + private redis?: Redis; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit() { + const redisUrl = this.configService.get('redis.url'); + if (!redisUrl) { + this.logger.warn('REDIS_URL is not configured. Redis caching is disabled.'); + return; + } + + this.redis = new Redis(redisUrl, { + lazyConnect: true, + maxRetriesPerRequest: 1, + }); + + this.redis.on('error', (error) => { + this.logger.error(`Redis error: ${error.message}`); + }); + + await this.redis.connect(); + } + + async onModuleDestroy() { + if (this.redis) { + await this.redis.quit(); + } + } + + async getJson(key: string): Promise { + if (!this.redis) { + return null; + } + + const value = await this.redis.get(key); + return value ? (JSON.parse(value) as T) : null; + } + + async setJson(key: string, value: unknown, ttlSeconds: number) { + if (!this.redis) { + return; + } + + await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds); + } + + async deleteByPattern(pattern: string) { + if (!this.redis) { + return; + } + + let cursor = '0'; + do { + const [nextCursor, keys] = await this.redis.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + 200, + ); + cursor = nextCursor; + + if (keys.length > 0) { + await this.redis.del(...keys); + } + } while (cursor !== '0'); + } +} diff --git a/src/modules/catalog/admin-products.controller.ts b/src/modules/catalog/admin-products.controller.ts index b8819f0f..f45e2699 100644 --- a/src/modules/catalog/admin-products.controller.ts +++ b/src/modules/catalog/admin-products.controller.ts @@ -29,9 +29,11 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { UserRole } from '../users/enums/user-role.enum'; import { CheckProductSlugDto } from './dto/check-product-slug.dto'; import { CreateProductDto } from './dto/create-product.dto'; +import { BulkUpdateProductPricesDto } from './dto/bulk-update-product-prices.dto'; import { FilterProductReviewsDto } from './dto/filter-product-reviews.dto'; import { FilterProductsDto } from './dto/filter-products.dto'; import { ModerateProductReviewDto } from './dto/moderate-product-review.dto'; +import { UpdateProductPriceDto } from './dto/update-product-price.dto'; import { UpdateProductDto } from './dto/update-product.dto'; import { ProductsService } from './products.service'; @@ -115,6 +117,18 @@ export class AdminProductsController { return this.productsService.findAdminOne(id); } + @Patch(':id/price') + @ApiOperation({ summary: 'Update base USD price for one product' }) + updatePrice(@Param('id') id: string, @Body() dto: UpdateProductPriceDto) { + return this.productsService.updateBasePrice(id, dto.basePriceUSD); + } + + @Patch('prices/bulk') + @ApiOperation({ summary: 'Bulk update base USD prices for multiple products' }) + bulkUpdatePrices(@Body() dto: BulkUpdateProductPricesDto) { + return this.productsService.bulkUpdateBasePrices(dto.items); + } + @Patch(':id') @ApiOperation({ summary: 'Update a product for admin panel' }) @ApiConsumes('multipart/form-data') diff --git a/src/modules/catalog/catalog.module.ts b/src/modules/catalog/catalog.module.ts index 9102b459..3fc91746 100644 --- a/src/modules/catalog/catalog.module.ts +++ b/src/modules/catalog/catalog.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PricingModule } from '../pricing/pricing.module'; import { StorageModule } from '../storage/storage.module'; import { AdminProductsController } from './admin-products.controller'; import { AttributeDefinitionsController } from './attribute-definitions.controller'; @@ -30,6 +31,7 @@ import { ProductsService } from './products.service'; ProductAttributeValue, ]), StorageModule, + PricingModule, ], controllers: [ CategoryController, diff --git a/src/modules/catalog/dto/bulk-update-product-prices.dto.ts b/src/modules/catalog/dto/bulk-update-product-prices.dto.ts new file mode 100644 index 00000000..c8e599ac --- /dev/null +++ b/src/modules/catalog/dto/bulk-update-product-prices.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsNumber, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +class BulkUpdateProductPriceItemDto { + @ApiProperty() + @IsUUID() + productId: string; + + @ApiProperty({ example: 42.5 }) + @Type(() => Number) + @IsNumber() + @Min(0) + basePriceUSD: number; +} + +export class BulkUpdateProductPricesDto { + @ApiProperty({ type: [BulkUpdateProductPriceItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => BulkUpdateProductPriceItemDto) + items: BulkUpdateProductPriceItemDto[]; +} diff --git a/src/modules/catalog/dto/update-product-price.dto.ts b/src/modules/catalog/dto/update-product-price.dto.ts new file mode 100644 index 00000000..11c544ba --- /dev/null +++ b/src/modules/catalog/dto/update-product-price.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber, Min } from 'class-validator'; + +export class UpdateProductPriceDto { + @ApiProperty({ example: 42.5 }) + @Type(() => Number) + @IsNumber() + @Min(0) + basePriceUSD: number; +} diff --git a/src/modules/catalog/entities/product.entity.ts b/src/modules/catalog/entities/product.entity.ts index af91f809..bde7210a 100644 --- a/src/modules/catalog/entities/product.entity.ts +++ b/src/modules/catalog/entities/product.entity.ts @@ -11,6 +11,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { Exclude } from 'class-transformer'; import { ProductStatus } from '../enums/product-status.enum'; import { ProductType } from '../enums/product-type.enum'; import { Brand } from './brand.entity'; @@ -58,6 +59,7 @@ export class Product { from: (value: string) => Number(value), }, }) + @Exclude() basePriceUSD: number; @Column({ diff --git a/src/modules/catalog/product-details.controller.ts b/src/modules/catalog/product-details.controller.ts index cc17f8b3..e861e958 100644 --- a/src/modules/catalog/product-details.controller.ts +++ b/src/modules/catalog/product-details.controller.ts @@ -1,14 +1,19 @@ -import { Controller, Get, Headers, Param } from '@nestjs/common'; -import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Headers, Param, Req, UseGuards } from '@nestjs/common'; +import { Request } from 'express'; +import { ApiBearerAuth, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { OptionalJwtAuthGuard } from '../auth/guards/optional-jwt-auth.guard'; +import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; import { ProductsService } from './products.service'; @ApiTags('Products') +@ApiBearerAuth() @ApiHeader({ name: 'x-product-type', required: false, description: 'Optional product type filter header. Falls back to query param `type` if omitted.', enum: ['Industrial', 'Automotive'], }) +@UseGuards(OptionalJwtAuthGuard) @Controller('product') export class ProductDetailsController { constructor(private readonly productsService: ProductsService) {} @@ -18,7 +23,12 @@ export class ProductDetailsController { findBySlug( @Param('slug') slug: string, @Headers('x-product-type') productType?: string, + @Req() request?: Request & { user?: JwtPayload | null }, ) { - return this.productsService.findPublicOneBySlug(slug, productType); + return this.productsService.findPublicOneBySlug( + slug, + productType, + request?.user, + ); } } diff --git a/src/modules/catalog/products.controller.ts b/src/modules/catalog/products.controller.ts index a0771795..028c69d0 100644 --- a/src/modules/catalog/products.controller.ts +++ b/src/modules/catalog/products.controller.ts @@ -1,16 +1,37 @@ -import { Body, Controller, Get, Headers, Param, Post, Query } from '@nestjs/common'; -import { ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Get, + Headers, + Param, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { Request } from 'express'; +import { + ApiBearerAuth, + ApiBody, + ApiHeader, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; +import { OptionalJwtAuthGuard } from '../auth/guards/optional-jwt-auth.guard'; import { CreateProductReviewDto } from './dto/create-product-review.dto'; import { FilterProductsDto } from './dto/filter-products.dto'; import { ProductsService } from './products.service'; @ApiTags('Products') +@ApiBearerAuth() @ApiHeader({ name: 'x-product-type', required: false, description: 'Optional product type filter header. Falls back to query param `type` if omitted.', enum: ['Industrial', 'Automotive'], }) +@UseGuards(OptionalJwtAuthGuard) @Controller('products') export class ProductsController { constructor(private readonly productsService: ProductsService) {} @@ -20,8 +41,9 @@ export class ProductsController { findAll( @Query() filters: FilterProductsDto, @Headers('x-product-type') productType?: string, + @Req() request?: Request & { user?: JwtPayload | null }, ) { - return this.productsService.findPublic(filters, productType); + return this.productsService.findPublic(filters, productType, request?.user); } @Get('brands/:brandSlug') @@ -30,11 +52,13 @@ export class ProductsController { @Param('brandSlug') brandSlug: string, @Query() filters: FilterProductsDto, @Headers('x-product-type') productType?: string, + @Req() request?: Request & { user?: JwtPayload | null }, ) { return this.productsService.findPublicByBrandSlug( brandSlug, filters, productType, + request?.user, ); } @@ -58,11 +82,13 @@ export class ProductsController { @Param('categorySlug') categorySlug: string, @Query() filters: FilterProductsDto, @Headers('x-product-type') productType?: string, + @Req() request?: Request & { user?: JwtPayload | null }, ) { return this.productsService.findPublicByCategorySlug( categorySlug, filters, productType, + request?.user, ); } @@ -95,7 +121,11 @@ export class ProductsController { @Get(':id') @ApiOperation({ summary: 'Get one published product with approved reviews summary' }) - findOne(@Param('id') id: string, @Headers('x-product-type') productType?: string) { - return this.productsService.findPublicOne(id, productType); + findOne( + @Param('id') id: string, + @Headers('x-product-type') productType?: string, + @Req() request?: Request & { user?: JwtPayload | null }, + ) { + return this.productsService.findPublicOne(id, productType, request?.user); } } diff --git a/src/modules/catalog/products.service.ts b/src/modules/catalog/products.service.ts index ac8c195e..b07689c5 100644 --- a/src/modules/catalog/products.service.ts +++ b/src/modules/catalog/products.service.ts @@ -5,6 +5,9 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { SelectQueryBuilder, Repository } from 'typeorm'; +import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; +import { RedisCacheService } from '../cache/redis-cache.service'; +import { PricingContext, PricingService } from '../pricing/pricing.service'; import { StorageService } from '../storage/storage.service'; import { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto'; import { CreateProductDto } from './dto/create-product.dto'; @@ -45,6 +48,8 @@ export class ProductsService { private readonly categoriesRepository: Repository, @InjectRepository(Brand) private readonly brandsRepository: Repository, + private readonly pricingService: PricingService, + private readonly redisCacheService: RedisCacheService, private readonly storageService: StorageService, ) {} @@ -112,27 +117,38 @@ export class ProductsService { dto.attributes ?? [], ); + await this.invalidatePriceCache(); return this.serializeProduct(await this.findOneById(product.id)); } - async findPublic(filters: FilterProductsDto, headerProductType?: string) { - return this.findAll(this.mergeProductTypeFilter(filters, headerProductType), false); + async findPublic( + filters: FilterProductsDto, + headerProductType?: string, + user?: JwtPayload | null, + ) { + return this.findPublicList( + this.mergeProductTypeFilter(filters, headerProductType), + user, + 'products', + ); } async findPublicByBrandSlug( brandSlug: string, filters: FilterProductsDto, headerProductType?: string, + user?: JwtPayload | null, ) { const mergedFilters = this.mergeProductTypeFilter(filters, headerProductType); const brand = await this.resolveBrandBySlug(brandSlug, mergedFilters.type); - return this.findAll( + return this.findPublicList( { ...mergedFilters, brandId: brand.id, }, - false, + user, + `brand:${brand.slug}`, ); } @@ -140,16 +156,18 @@ export class ProductsService { categorySlug: string, filters: FilterProductsDto, headerProductType?: string, + user?: JwtPayload | null, ) { const mergedFilters = this.mergeProductTypeFilter(filters, headerProductType); const category = await this.resolveCategoryBySlug(categorySlug, mergedFilters.type); - return this.findAll( + return this.findPublicList( { ...mergedFilters, categoryId: category.id, }, - false, + user, + `category:${category.slug}`, ); } @@ -199,52 +217,30 @@ export class ProductsService { return !existing; } - async findPublicOne(id: string, headerProductType?: string) { - const product = await this.productsRepository.findOne({ - where: this.buildPublicProductWhere({ id }, headerProductType), - relations: { - primaryCategory: true, - categories: true, - brandEntity: true, - meta: true, - attributeValues: { attribute: true }, - }, - }); - - if (!product) { - throw new NotFoundException('Product not found'); - } - - const approvedReviews = await this.findApprovedReviewsInternal(product.id, 10); - - return { - ...this.serializeProduct(product), - approvedReviews, - }; + async findPublicOne( + id: string, + headerProductType?: string, + user?: JwtPayload | null, + ) { + const normalizedType = this.normalizeProductType(headerProductType); + return this.findPublicProductDetail( + { id, ...(normalizedType ? { type: normalizedType } : {}) }, + user, + `id:${id}`, + ); } - async findPublicOneBySlug(slug: string, headerProductType?: string) { - const product = await this.productsRepository.findOne({ - where: this.buildPublicProductWhere({ slug }, headerProductType), - relations: { - primaryCategory: true, - categories: true, - brandEntity: true, - meta: true, - attributeValues: { attribute: true }, - }, - }); - - if (!product) { - throw new NotFoundException('Product not found'); - } - - const approvedReviews = await this.findApprovedReviewsInternal(product.id, 10); - - return { - ...this.serializeProduct(product), - approvedReviews, - }; + async findPublicOneBySlug( + slug: string, + headerProductType?: string, + user?: JwtPayload | null, + ) { + const normalizedType = this.normalizeProductType(headerProductType); + return this.findPublicProductDetail( + { slug, ...(normalizedType ? { type: normalizedType } : {}) }, + user, + `slug:${slug}`, + ); } async findAdminOne(id: string) { @@ -447,6 +443,7 @@ export class ProductsService { product.attributeValues = await this.syncAttributeValues(product, dto.attributes); } + await this.invalidatePriceCache(); return this.serializeProduct(await this.findOneById(product.id)); } @@ -457,10 +454,43 @@ export class ProductsService { await this.storageService.deletePublicFileByUrl(product.mainImageUrl); await this.storageService.deletePublicFileByUrl(product.meta?.shareImageUrl); await this.productsRepository.remove(product); + await this.invalidatePriceCache(); return { message: 'Product deleted successfully' }; } + async updateBasePrice(id: string, basePriceUSD: number) { + const product = await this.findOneById(id); + product.basePriceUSD = basePriceUSD; + await this.productsRepository.save(product); + await this.invalidatePriceCache(); + + return this.findAdminOne(id); + } + + async bulkUpdateBasePrices(items: Array<{ productId: string; basePriceUSD: number }>) { + await this.productsRepository.manager.transaction(async (entityManager) => { + for (const item of items) { + const existingProduct = await entityManager.findOne(Product, { + where: { id: item.productId }, + }); + + if (!existingProduct) { + throw new NotFoundException(`Product not found: ${item.productId}`); + } + + existingProduct.basePriceUSD = item.basePriceUSD; + await entityManager.save(Product, existingProduct); + } + }); + + await this.invalidatePriceCache(); + + return { + updatedCount: items.length, + }; + } + async listAttributeDefinitions() { return this.attributeDefinitionsRepository.find({ order: { @@ -527,6 +557,10 @@ export class ProductsService { } private async findAll(filters: FilterProductsDto, includeUnpublished: boolean) { + if (!includeUnpublished) { + return this.findPublicList(filters, null, 'products'); + } + const page = filters.page ?? 1; const limit = filters.limit ?? 20; @@ -537,11 +571,7 @@ export class ProductsService { const [items, total] = await query.getManyAndCount(); return { - items: items.map((item) => - includeUnpublished - ? this.serializeAdminProductListItem(item) - : this.serializePublicProductListItem(item), - ), + items: items.map((item) => this.serializeAdminProductListItem(item)), meta: { total, page, @@ -550,6 +580,78 @@ export class ProductsService { }; } + private async findPublicList( + filters: FilterProductsDto, + user: JwtPayload | null | undefined, + scope: string, + ) { + const page = filters.page ?? 1; + const limit = filters.limit ?? 20; + const pricingContext = await this.pricingService.getPricingContext(user); + const cacheKey = this.pricingService.buildListCacheKey({ + userLevel: pricingContext.userLevel, + categoryId: filters.categoryId, + page, + scope, + filters: { + ...filters, + limit, + }, + }); + + const cached = await this.redisCacheService.getJson<{ + items: ReturnType[]; + meta: { total: number; page: number; limit: number }; + }>(cacheKey); + if (cached) { + return cached; + } + + const total = await this.createFilteredProductsQuery(filters, false) + .select('product.id') + .distinct(true) + .getCount(); + + const rawRows = await this.pricingService + .applyCalculatedPriceSelect( + this.createFilteredProductsQuery(filters, false) + .select('product.id', 'product_id') + .distinct(true) + .skip((page - 1) * limit) + .take(limit), + pricingContext, + ) + .getRawMany<{ product_id: string; calculated_price: string }>(); + + const ids = rawRows.map((item) => item.product_id); + const products = ids.length > 0 ? await this.findManyByIds(ids) : []; + const productMap = new Map(products.map((product) => [product.id, product])); + const priceMap = new Map( + rawRows.map((item) => [item.product_id, Number(item.calculated_price)]), + ); + + const response = { + items: ids + .map((id) => productMap.get(id)) + .filter((product): product is Product => Boolean(product)) + .map((product) => + this.serializePublicProductListItem( + product, + pricingContext, + priceMap.get(product.id) ?? 0, + ), + ), + meta: { + total, + page, + limit, + }, + }; + + await this.redisCacheService.setJson(cacheKey, response, 300); + return response; + } + private createFilteredProductsQuery( filters: FilterProductsDto, includeUnpublished: boolean, @@ -851,6 +953,59 @@ export class ProductsService { return null; } + private async findPublicProductDetail( + where: Partial>, + user: JwtPayload | null | undefined, + cacheScope: string, + ) { + const pricingContext = await this.pricingService.getPricingContext(user); + const cacheKey = this.pricingService.buildDetailCacheKey({ + userLevel: pricingContext.userLevel, + slugOrId: cacheScope, + scope: where.slug ? 'slug' : 'id', + }); + const cached = await this.redisCacheService.getJson(cacheKey); + if (cached) { + return cached; + } + + const rawProduct = await this.pricingService + .applyCalculatedPriceSelect( + this.productsRepository + .createQueryBuilder('product') + .select('product.id', 'product_id') + .where('product.status = :publishedStatus', { + publishedStatus: ProductStatus.PUBLISHED, + }) + .andWhere(where.id ? 'product.id = :id' : 'product.slug = :slug', { + ...(where.id ? { id: where.id } : { slug: where.slug }), + }) + .andWhere(where.type ? 'product.type = :type' : '1=1', { + ...(where.type ? { type: where.type } : {}), + }), + pricingContext, + ) + .getRawOne<{ product_id: string; calculated_price: string }>(); + + if (!rawProduct?.product_id) { + throw new NotFoundException('Product not found'); + } + + const product = await this.findOneById(rawProduct.product_id); + const approvedReviews = await this.findApprovedReviewsInternal(product.id, 10); + const response = { + ...this.serializePublicProductDetail( + product, + pricingContext, + Number(rawProduct.calculated_price), + ), + approvedReviews, + }; + + await this.redisCacheService.setJson(cacheKey, response, 300); + return response; + } + private async findOneById(id: string) { const product = await this.productsRepository.findOne({ where: { id }, @@ -870,6 +1025,23 @@ export class ProductsService { return product; } + private async findManyByIds(ids: string[]) { + if (ids.length === 0) { + return []; + } + + return this.productsRepository + .createQueryBuilder('product') + .leftJoinAndSelect('product.primaryCategory', 'primaryCategory') + .leftJoinAndSelect('product.categories', 'categories') + .leftJoinAndSelect('product.brandEntity', 'brandEntity') + .leftJoinAndSelect('product.meta', 'meta') + .leftJoinAndSelect('product.attributeValues', 'attributeValues') + .leftJoinAndSelect('attributeValues.attribute', 'attributeDefinition') + .where('product.id IN (:...ids)', { ids }) + .getMany(); + } + private async resolveCategories( primaryCategoryId: string | undefined, categoryIds: string[] | undefined, @@ -1191,8 +1363,11 @@ export class ProductsService { }; } - private serializePublicProductListItem(product: Product) { - const meta = product.meta ?? null; + private serializePublicProductListItem( + product: Product, + pricingContext: PricingContext, + calculatedPrice: number, + ) { const orderedAttributes = this.sortAttributes(product.attributeValues).filter( (item) => item.attribute?.isVisible ?? true, ); @@ -1203,8 +1378,8 @@ export class ProductsService { slug: product.slug, brand: product.brand, brandslug: product.brandEntity?.slug ?? null, - basePriceUSD: product.basePriceUSD, - salePriceUSD: product.salePriceUSD ?? null, + calculated_price: calculatedPrice, + currency_unit: pricingContext.currencyUnit, stock: product.stock, averageRating: product.averageRating, reviewsCount: product.reviewsCount, @@ -1239,6 +1414,99 @@ export class ProductsService { }; } + private serializePublicProductDetail( + product: Product, + pricingContext: PricingContext, + calculatedPrice: number, + ) { + const meta = product.meta ?? null; + const shortDescription = meta?.shortDescription ?? null; + const share = { + title: meta?.shareTitle ?? product.title, + description: meta?.shareDescription ?? shortDescription, + imageUrl: meta?.shareImageUrl ?? product.mainImageUrl, + }; + const seo = { + title: meta?.metaTitle ?? product.title, + description: meta?.metaDescription ?? shortDescription, + }; + const orderedAttributes = this.sortAttributes(product.attributeValues); + + return { + id: product.id, + sku: product.sku, + title: product.title, + slug: product.slug, + technicalCode: product.technicalCode, + brand: product.brand, + brandInfo: product.brandEntity + ? { + id: product.brandEntity.id, + name: product.brandEntity.name, + slug: product.brandEntity.slug, + imageUrl: product.brandEntity.imageUrl, + type: product.brandEntity.type, + } + : null, + calculated_price: calculatedPrice, + currency_unit: pricingContext.currencyUnit, + stock: product.stock, + featured: product.featured, + type: product.type, + status: product.status, + averageRating: product.averageRating, + reviewsCount: product.reviewsCount, + mainImageUrl: product.mainImageUrl ?? null, + threeDModelUrl: product.threeDModelUrl ?? null, + imageGalleryUrls: product.imageGalleryUrls ?? [], + tags: product.tags ?? [], + primaryCategory: product.primaryCategory + ? { + id: product.primaryCategory.id, + name: product.primaryCategory.name, + slug: product.primaryCategory.slug, + imageUrl: product.primaryCategory.imageUrl, + type: product.primaryCategory.type, + } + : null, + categories: (product.categories ?? []).map((category) => ({ + id: category.id, + name: category.name, + slug: category.slug, + imageUrl: category.imageUrl, + type: category.type, + })), + meta: { + shortDescription, + description: meta?.description ?? null, + seo, + share, + }, + attributes: orderedAttributes.map((item) => ({ + id: item.id, + displayOrder: item.displayOrder ?? 0, + attributeId: item.attribute?.id, + name: item.attribute?.name, + slug: item.attribute?.slug, + dataType: item.attribute?.dataType, + unit: item.overrideUnit ?? item.attribute?.unit ?? null, + options: item.attribute?.options ?? [], + isFilterable: item.attribute?.isFilterable ?? false, + isVisible: item.attribute?.isVisible ?? true, + valueText: item.valueText ?? item.attribute?.defaultValueText ?? null, + valueNumber: + item.valueNumber ?? + (item.attribute?.defaultValueNumber !== null && + item.attribute?.defaultValueNumber !== undefined + ? Number(item.attribute.defaultValueNumber) + : null), + valueBoolean: + item.valueBoolean ?? item.attribute?.defaultValueBoolean ?? null, + valueJson: item.valueJson ?? item.attribute?.defaultValueJson ?? null, + })), + }; + } + private serializeAdminProductListItem(product: Product) { const meta = product.meta ?? null; const orderedAttributes = this.sortAttributes(product.attributeValues).filter( @@ -1384,4 +1652,9 @@ export class ProductsService { await this.storageService.deletePublicFileByUrl(currentUrl); } } + + private async invalidatePriceCache() { + await this.redisCacheService.deleteByPattern('prices:*'); + await this.redisCacheService.deleteByPattern('product-price:*'); + } } diff --git a/src/modules/pricing/pricing.module.ts b/src/modules/pricing/pricing.module.ts new file mode 100644 index 00000000..d4cc40d4 --- /dev/null +++ b/src/modules/pricing/pricing.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SettingsModule } from '../settings/settings.module'; +import { UsersModule } from '../users/users.module'; +import { PricingService } from './pricing.service'; + +@Module({ + imports: [SettingsModule, UsersModule], + providers: [PricingService], + exports: [PricingService], +}) +export class PricingModule {} diff --git a/src/modules/pricing/pricing.service.ts b/src/modules/pricing/pricing.service.ts new file mode 100644 index 00000000..a30672c4 --- /dev/null +++ b/src/modules/pricing/pricing.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { SelectQueryBuilder } from 'typeorm'; +import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; +import { Product } from '../catalog/entities/product.entity'; +import { UsersService } from '../users/users.service'; +import { UserLevel } from '../users/enums/user-level.enum'; +import { SettingsService } from '../settings/settings.service'; +import { CurrencyDisplay } from '../settings/enums/currency-display.enum'; + +export interface PricingContext { + usdToIrrRate: number; + currencyDisplay: CurrencyDisplay; + userLevel: UserLevel | 'Guest'; + discountPercent: number; + currencyUnit: CurrencyDisplay; +} + +@Injectable() +export class PricingService { + private static readonly USER_LEVEL_DISCOUNTS: Record = { + [UserLevel.BRONZE]: 0, + [UserLevel.SILVER]: 5, + [UserLevel.GOLD]: 10, + }; + + constructor( + private readonly settingsService: SettingsService, + private readonly usersService: UsersService, + ) {} + + async getPricingContext(user?: JwtPayload | null): Promise { + const settings = await this.settingsService.getSettings(); + const userLevel = await this.resolveUserLevel(user); + const discountPercent = + userLevel === 'Guest' + ? 0 + : PricingService.USER_LEVEL_DISCOUNTS[userLevel] ?? 0; + + return { + usdToIrrRate: settings.usdToIrrRate, + currencyDisplay: settings.defaultCurrencyDisplay, + userLevel, + discountPercent, + currencyUnit: settings.defaultCurrencyDisplay, + }; + } + + applyCalculatedPriceSelect( + query: SelectQueryBuilder, + pricingContext: PricingContext, + alias = 'product', + selectAlias = 'calculated_price', + ) { + query + .addSelect( + this.buildCalculatedPriceSql(alias, pricingContext.currencyDisplay), + selectAlias, + ) + .setParameter('current_usd_rate', pricingContext.usdToIrrRate) + .setParameter('user_discount_percent', pricingContext.discountPercent); + + return query; + } + + buildListCacheKey(input: { + userLevel: PricingContext['userLevel']; + categoryId?: string; + page: number; + filters: Record; + scope?: string; + }) { + const digest = createHash('sha1') + .update(JSON.stringify(input.filters)) + .digest('hex') + .slice(0, 12); + + return `prices:${input.userLevel}:${input.categoryId ?? 'all'}:${input.page}:${input.scope ?? 'list'}:${digest}`; + } + + buildDetailCacheKey(input: { + userLevel: PricingContext['userLevel']; + slugOrId: string; + scope: 'slug' | 'id'; + }) { + return `product-price:${input.userLevel}:${input.scope}:${input.slugOrId}`; + } + + private buildCalculatedPriceSql(alias: string, currencyDisplay: CurrencyDisplay) { + const irrCalculation = `(${alias}.base_price_usd * :current_usd_rate) * (1 - :user_discount_percent / 100.0)`; + if (currencyDisplay === CurrencyDisplay.TOMAN) { + return `ROUND((${irrCalculation}) / 10.0, 0)`; + } + + return `ROUND(${irrCalculation}, 0)`; + } + + private async resolveUserLevel(user?: JwtPayload | null) { + if (!user?.sub) { + return 'Guest' as const; + } + + const existingUser = await this.usersService.findById(user.sub); + return existingUser?.loyaltyProfile?.currentLevel ?? UserLevel.BRONZE; + } +} diff --git a/src/modules/settings/dto/update-pricing-settings.dto.ts b/src/modules/settings/dto/update-pricing-settings.dto.ts new file mode 100644 index 00000000..c617a1c1 --- /dev/null +++ b/src/modules/settings/dto/update-pricing-settings.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsNumber, Min } from 'class-validator'; +import { CurrencyDisplay } from '../enums/currency-display.enum'; + +export class UpdatePricingSettingsDto { + @ApiProperty({ example: 925000 }) + @Type(() => Number) + @IsNumber() + @Min(0) + usdToIrrRate: number; + + @ApiPropertyOptional({ enum: CurrencyDisplay, example: CurrencyDisplay.TOMAN }) + @IsEnum(CurrencyDisplay) + defaultCurrencyDisplay: CurrencyDisplay; +} diff --git a/src/modules/settings/entities/settings.entity.ts b/src/modules/settings/entities/settings.entity.ts new file mode 100644 index 00000000..244b6d0a --- /dev/null +++ b/src/modules/settings/entities/settings.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { CurrencyDisplay } from '../enums/currency-display.enum'; + +@Entity({ name: 'settings' }) +export class Settings { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ + name: 'usd_to_irr_rate', + type: 'numeric', + precision: 18, + scale: 2, + default: 0, + transformer: { + to: (value: number) => value, + from: (value: string) => Number(value), + }, + }) + usdToIrrRate: number; + + @Column({ + name: 'default_currency_display', + type: 'enum', + enum: CurrencyDisplay, + default: CurrencyDisplay.IRR, + }) + defaultCurrencyDisplay: CurrencyDisplay; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/settings/enums/currency-display.enum.ts b/src/modules/settings/enums/currency-display.enum.ts new file mode 100644 index 00000000..c88babec --- /dev/null +++ b/src/modules/settings/enums/currency-display.enum.ts @@ -0,0 +1,4 @@ +export enum CurrencyDisplay { + IRR = 'IRR', + TOMAN = 'TOMAN', +} diff --git a/src/modules/settings/settings.controller.ts b/src/modules/settings/settings.controller.ts new file mode 100644 index 00000000..58f817d3 --- /dev/null +++ b/src/modules/settings/settings.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Permissions } from '../../common/decorators/permissions.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { UpdatePricingSettingsDto } from './dto/update-pricing-settings.dto'; +import { SettingsService } from './settings.service'; + +@ApiTags('Admin Settings') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) +@Roles(UserRole.ADMIN) +@Permissions('products.manage') +@Controller('admin/settings') +export class SettingsController { + constructor(private readonly settingsService: SettingsService) {} + + @Get('pricing') + @ApiOperation({ summary: 'Get pricing settings' }) + findPricingSettings() { + return this.settingsService.getSettings(); + } + + @Patch('pricing') + @ApiOperation({ summary: 'Update pricing settings and invalidate product price cache' }) + updatePricingSettings(@Body() dto: UpdatePricingSettingsDto) { + return this.settingsService.updatePricingSettings(dto); + } +} diff --git a/src/modules/settings/settings.module.ts b/src/modules/settings/settings.module.ts new file mode 100644 index 00000000..29fac505 --- /dev/null +++ b/src/modules/settings/settings.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { SettingsController } from './settings.controller'; +import { Settings } from './entities/settings.entity'; +import { SettingsService } from './settings.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Settings]), AuthModule], + controllers: [SettingsController], + providers: [SettingsService], + exports: [SettingsService, TypeOrmModule], +}) +export class SettingsModule {} diff --git a/src/modules/settings/settings.service.ts b/src/modules/settings/settings.service.ts new file mode 100644 index 00000000..79228b9e --- /dev/null +++ b/src/modules/settings/settings.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RedisCacheService } from '../cache/redis-cache.service'; +import { UpdatePricingSettingsDto } from './dto/update-pricing-settings.dto'; +import { Settings } from './entities/settings.entity'; +import { CurrencyDisplay } from './enums/currency-display.enum'; + +@Injectable() +export class SettingsService { + constructor( + @InjectRepository(Settings) + private readonly settingsRepository: Repository, + private readonly redisCacheService: RedisCacheService, + ) {} + + async getSettings() { + const existingSettings = await this.settingsRepository + .createQueryBuilder('settings') + .orderBy('settings.createdAt', 'ASC') + .getOne(); + + if (existingSettings) { + return existingSettings; + } + + return this.settingsRepository.save( + this.settingsRepository.create({ + usdToIrrRate: 0, + defaultCurrencyDisplay: CurrencyDisplay.IRR, + }), + ); + } + + async updatePricingSettings(dto: UpdatePricingSettingsDto) { + const settings = await this.getSettings(); + settings.usdToIrrRate = dto.usdToIrrRate; + settings.defaultCurrencyDisplay = dto.defaultCurrencyDisplay; + + const savedSettings = await this.settingsRepository.save(settings); + await this.redisCacheService.deleteByPattern('prices:*'); + await this.redisCacheService.deleteByPattern('product-price:*'); + + return savedSettings; + } +}