add redis and prining

This commit is contained in:
2026-03-29 16:14:34 +03:00
parent 6b002edd34
commit 7825acaa97
23 changed files with 916 additions and 67 deletions

79
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View 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
View 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 {}

View 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');
}
}

View File

@@ -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')

View File

@@ -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,

View 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[];
}

View 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;
}

View File

@@ -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({

View File

@@ -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,
);
}
}

View File

@@ -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);
}
}

View File

@@ -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:*');
}
}

View 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 {}

View 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;
}
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,4 @@
export enum CurrencyDisplay {
IRR = 'IRR',
TOMAN = 'TOMAN',
}

View 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);
}
}

View 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 {}

View 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;
}
}