add redis and prining
This commit is contained in:
79
package-lock.json
generated
79
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
21
src/modules/auth/guards/optional-jwt-auth.guard.ts
Normal file
21
src/modules/auth/guards/optional-jwt-auth.guard.ts
Normal file
@@ -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<TUser = unknown>(err: unknown, user: TUser) {
|
||||
if (err) {
|
||||
return null as TUser;
|
||||
}
|
||||
|
||||
return (user ?? null) as TUser;
|
||||
}
|
||||
}
|
||||
11
src/modules/cache/redis-cache.module.ts
vendored
Normal file
11
src/modules/cache/redis-cache.module.ts
vendored
Normal file
@@ -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 {}
|
||||
80
src/modules/cache/redis-cache.service.ts
vendored
Normal file
80
src/modules/cache/redis-cache.service.ts
vendored
Normal file
@@ -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<string>('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<T>(key: string): Promise<T | null> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
31
src/modules/catalog/dto/bulk-update-product-prices.dto.ts
Normal file
31
src/modules/catalog/dto/bulk-update-product-prices.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
11
src/modules/catalog/dto/update-product-price.dto.ts
Normal file
11
src/modules/catalog/dto/update-product-price.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Category>,
|
||||
@InjectRepository(Brand)
|
||||
private readonly brandsRepository: Repository<Brand>,
|
||||
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<ProductsService['serializePublicProductListItem']>[];
|
||||
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<Pick<Product, 'id' | 'slug' | 'type'>>,
|
||||
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<unknown>(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:*');
|
||||
}
|
||||
}
|
||||
|
||||
11
src/modules/pricing/pricing.module.ts
Normal file
11
src/modules/pricing/pricing.module.ts
Normal file
@@ -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 {}
|
||||
106
src/modules/pricing/pricing.service.ts
Normal file
106
src/modules/pricing/pricing.service.ts
Normal file
@@ -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, number> = {
|
||||
[UserLevel.BRONZE]: 0,
|
||||
[UserLevel.SILVER]: 5,
|
||||
[UserLevel.GOLD]: 10,
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly settingsService: SettingsService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async getPricingContext(user?: JwtPayload | null): Promise<PricingContext> {
|
||||
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<Product>,
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
16
src/modules/settings/dto/update-pricing-settings.dto.ts
Normal file
16
src/modules/settings/dto/update-pricing-settings.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
41
src/modules/settings/entities/settings.entity.ts
Normal file
41
src/modules/settings/entities/settings.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
src/modules/settings/enums/currency-display.enum.ts
Normal file
4
src/modules/settings/enums/currency-display.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum CurrencyDisplay {
|
||||
IRR = 'IRR',
|
||||
TOMAN = 'TOMAN',
|
||||
}
|
||||
36
src/modules/settings/settings.controller.ts
Normal file
36
src/modules/settings/settings.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
14
src/modules/settings/settings.module.ts
Normal file
14
src/modules/settings/settings.module.ts
Normal file
@@ -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 {}
|
||||
46
src/modules/settings/settings.service.ts
Normal file
46
src/modules/settings/settings.service.ts
Normal file
@@ -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<Settings>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user