[SAAS-019] feat: Add Portfolio module backend
- 4 entities: Category, Product, Variant, Price - 2 services: CategoriesService, ProductsService - 2 controllers with full CRUD endpoints - DTOs for all operations - Registered in AppModule Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a2a1fd3d3b
commit
2921ca9e83
@ -30,6 +30,7 @@ import { AnalyticsModule } from '@modules/analytics/analytics.module';
|
|||||||
import { ReportsModule } from '@modules/reports/reports.module';
|
import { ReportsModule } from '@modules/reports/reports.module';
|
||||||
import { SalesModule } from '@modules/sales/sales.module';
|
import { SalesModule } from '@modules/sales/sales.module';
|
||||||
import { CommissionsModule } from '@modules/commissions/commissions.module';
|
import { CommissionsModule } from '@modules/commissions/commissions.module';
|
||||||
|
import { PortfolioModule } from '@modules/portfolio/portfolio.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -92,6 +93,7 @@ import { CommissionsModule } from '@modules/commissions/commissions.module';
|
|||||||
ReportsModule,
|
ReportsModule,
|
||||||
SalesModule,
|
SalesModule,
|
||||||
CommissionsModule,
|
CommissionsModule,
|
||||||
|
PortfolioModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
83
src/modules/portfolio/controllers/categories.controller.ts
Normal file
83
src/modules/portfolio/controllers/categories.controller.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||||
|
import { CategoriesService } from '../services';
|
||||||
|
import {
|
||||||
|
CreateCategoryDto,
|
||||||
|
UpdateCategoryDto,
|
||||||
|
CategoryResponseDto,
|
||||||
|
CategoryTreeNodeDto,
|
||||||
|
CategoryListQueryDto,
|
||||||
|
PaginatedCategoriesDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
interface RequestUser {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('portfolio/categories')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Query() query: CategoryListQueryDto,
|
||||||
|
): Promise<PaginatedCategoriesDto> {
|
||||||
|
return this.categoriesService.findAll(user.tenant_id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
async getTree(@CurrentUser() user: RequestUser): Promise<CategoryTreeNodeDto[]> {
|
||||||
|
return this.categoriesService.getTree(user.tenant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<CategoryResponseDto> {
|
||||||
|
return this.categoriesService.findOne(user.tenant_id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Body() dto: CreateCategoryDto,
|
||||||
|
): Promise<CategoryResponseDto> {
|
||||||
|
return this.categoriesService.create(user.tenant_id, user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateCategoryDto,
|
||||||
|
): Promise<CategoryResponseDto> {
|
||||||
|
return this.categoriesService.update(user.tenant_id, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
await this.categoriesService.remove(user.tenant_id, id);
|
||||||
|
return { message: 'Category deleted successfully' };
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/modules/portfolio/controllers/index.ts
Normal file
2
src/modules/portfolio/controllers/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './categories.controller';
|
||||||
|
export * from './products.controller';
|
||||||
188
src/modules/portfolio/controllers/products.controller.ts
Normal file
188
src/modules/portfolio/controllers/products.controller.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||||
|
import { ProductsService } from '../services';
|
||||||
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
UpdateProductStatusDto,
|
||||||
|
ProductResponseDto,
|
||||||
|
ProductListQueryDto,
|
||||||
|
PaginatedProductsDto,
|
||||||
|
CreateVariantDto,
|
||||||
|
UpdateVariantDto,
|
||||||
|
VariantResponseDto,
|
||||||
|
CreatePriceDto,
|
||||||
|
UpdatePriceDto,
|
||||||
|
PriceResponseDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
interface RequestUser {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('portfolio/products')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ProductsController {
|
||||||
|
constructor(private readonly productsService: ProductsService) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Products
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Query() query: ProductListQueryDto,
|
||||||
|
): Promise<PaginatedProductsDto> {
|
||||||
|
return this.productsService.findAll(user.tenant_id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<ProductResponseDto> {
|
||||||
|
return this.productsService.findOne(user.tenant_id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Body() dto: CreateProductDto,
|
||||||
|
): Promise<ProductResponseDto> {
|
||||||
|
return this.productsService.create(user.tenant_id, user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateProductDto,
|
||||||
|
): Promise<ProductResponseDto> {
|
||||||
|
return this.productsService.update(user.tenant_id, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
async updateStatus(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateProductStatusDto,
|
||||||
|
): Promise<ProductResponseDto> {
|
||||||
|
return this.productsService.updateStatus(user.tenant_id, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/duplicate')
|
||||||
|
async duplicate(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<ProductResponseDto> {
|
||||||
|
return this.productsService.duplicate(user.tenant_id, user.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
await this.productsService.remove(user.tenant_id, id);
|
||||||
|
return { message: 'Product deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Variants
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@Get(':id/variants')
|
||||||
|
async getVariants(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) productId: string,
|
||||||
|
): Promise<VariantResponseDto[]> {
|
||||||
|
return this.productsService.getVariants(user.tenant_id, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/variants')
|
||||||
|
async createVariant(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) productId: string,
|
||||||
|
@Body() dto: CreateVariantDto,
|
||||||
|
): Promise<VariantResponseDto> {
|
||||||
|
return this.productsService.createVariant(user.tenant_id, productId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/variants/:variantId')
|
||||||
|
async updateVariant(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) productId: string,
|
||||||
|
@Param('variantId', ParseUUIDPipe) variantId: string,
|
||||||
|
@Body() dto: UpdateVariantDto,
|
||||||
|
): Promise<VariantResponseDto> {
|
||||||
|
return this.productsService.updateVariant(user.tenant_id, productId, variantId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/variants/:variantId')
|
||||||
|
async removeVariant(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) productId: string,
|
||||||
|
@Param('variantId', ParseUUIDPipe) variantId: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
await this.productsService.removeVariant(user.tenant_id, productId, variantId);
|
||||||
|
return { message: 'Variant deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Prices
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@Get(':id/prices')
|
||||||
|
async getPrices(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) productId: string,
|
||||||
|
): Promise<PriceResponseDto[]> {
|
||||||
|
return this.productsService.getPrices(user.tenant_id, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/prices')
|
||||||
|
async createPrice(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) productId: string,
|
||||||
|
@Body() dto: CreatePriceDto,
|
||||||
|
): Promise<PriceResponseDto> {
|
||||||
|
return this.productsService.createPrice(user.tenant_id, productId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/prices/:priceId')
|
||||||
|
async updatePrice(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) productId: string,
|
||||||
|
@Param('priceId', ParseUUIDPipe) priceId: string,
|
||||||
|
@Body() dto: UpdatePriceDto,
|
||||||
|
): Promise<PriceResponseDto> {
|
||||||
|
return this.productsService.updatePrice(user.tenant_id, productId, priceId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/prices/:priceId')
|
||||||
|
async removePrice(
|
||||||
|
@CurrentUser() user: RequestUser,
|
||||||
|
@Param('id', ParseUUIDPipe) productId: string,
|
||||||
|
@Param('priceId', ParseUUIDPipe) priceId: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
await this.productsService.removePrice(user.tenant_id, productId, priceId);
|
||||||
|
return { message: 'Price deleted successfully' };
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/modules/portfolio/dto/category.dto.ts
Normal file
177
src/modules/portfolio/dto/category.dto.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsUUID,
|
||||||
|
IsObject,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
position?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsOptional()
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(7)
|
||||||
|
@IsOptional()
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
@IsOptional()
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
@IsOptional()
|
||||||
|
metaTitle?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
metaDescription?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
customFields?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
@IsOptional()
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
position?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsOptional()
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(7)
|
||||||
|
@IsOptional()
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
@IsOptional()
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
@IsOptional()
|
||||||
|
metaTitle?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
metaDescription?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
customFields?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CategoryResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
position: number;
|
||||||
|
imageUrl: string | null;
|
||||||
|
color: string;
|
||||||
|
icon: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
metaTitle: string | null;
|
||||||
|
metaDescription: string | null;
|
||||||
|
customFields: Record<string, any>;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
createdBy: string | null;
|
||||||
|
children?: CategoryResponseDto[];
|
||||||
|
productCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CategoryTreeNodeDto extends CategoryResponseDto {
|
||||||
|
children: CategoryTreeNodeDto[];
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CategoryListQueryDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaginatedCategoriesDto {
|
||||||
|
items: CategoryResponseDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
2
src/modules/portfolio/dto/index.ts
Normal file
2
src/modules/portfolio/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './category.dto';
|
||||||
|
export * from './product.dto';
|
||||||
731
src/modules/portfolio/dto/product.dto.ts
Normal file
731
src/modules/portfolio/dto/product.dto.ts
Normal file
@ -0,0 +1,731 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsUUID,
|
||||||
|
IsEnum,
|
||||||
|
IsArray,
|
||||||
|
IsObject,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ProductType, ProductStatus } from '../entities';
|
||||||
|
import { PriceType } from '../entities/price.entity';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Product DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateProductDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(280)
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
sku?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
barcode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsOptional()
|
||||||
|
shortDescription?: string;
|
||||||
|
|
||||||
|
@IsEnum(ProductType)
|
||||||
|
@IsOptional()
|
||||||
|
productType?: ProductType;
|
||||||
|
|
||||||
|
@IsEnum(ProductStatus)
|
||||||
|
@IsOptional()
|
||||||
|
status?: ProductStatus;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
basePrice?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
costPrice?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
compareAtPrice?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
@IsOptional()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
trackInventory?: boolean;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
stockQuantity?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
lowStockThreshold?: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
allowBackorder?: boolean;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
@IsOptional()
|
||||||
|
weightUnit?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
length?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
width?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
height?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
@IsOptional()
|
||||||
|
dimensionUnit?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
images?: string[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsOptional()
|
||||||
|
featuredImageUrl?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
@IsOptional()
|
||||||
|
metaTitle?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
metaDescription?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isVisible?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isFeatured?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
hasVariants?: boolean;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
variantAttributes?: string[];
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
customFields?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateProductDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(280)
|
||||||
|
@IsOptional()
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
categoryId?: string | null;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
sku?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
barcode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsOptional()
|
||||||
|
shortDescription?: string;
|
||||||
|
|
||||||
|
@IsEnum(ProductType)
|
||||||
|
@IsOptional()
|
||||||
|
productType?: ProductType;
|
||||||
|
|
||||||
|
@IsEnum(ProductStatus)
|
||||||
|
@IsOptional()
|
||||||
|
status?: ProductStatus;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
basePrice?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
costPrice?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
compareAtPrice?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
@IsOptional()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
trackInventory?: boolean;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
stockQuantity?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
lowStockThreshold?: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
allowBackorder?: boolean;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
@IsOptional()
|
||||||
|
weightUnit?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
length?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
width?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
height?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
@IsOptional()
|
||||||
|
dimensionUnit?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
images?: string[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsOptional()
|
||||||
|
featuredImageUrl?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
@IsOptional()
|
||||||
|
metaTitle?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
metaDescription?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isVisible?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isFeatured?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
hasVariants?: boolean;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
variantAttributes?: string[];
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
customFields?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateProductStatusDto {
|
||||||
|
@IsEnum(ProductStatus)
|
||||||
|
status: ProductStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
categoryId: string | null;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
sku: string | null;
|
||||||
|
barcode: string | null;
|
||||||
|
description: string | null;
|
||||||
|
shortDescription: string | null;
|
||||||
|
productType: ProductType;
|
||||||
|
status: ProductStatus;
|
||||||
|
basePrice: number;
|
||||||
|
costPrice: number | null;
|
||||||
|
compareAtPrice: number | null;
|
||||||
|
currency: string;
|
||||||
|
trackInventory: boolean;
|
||||||
|
stockQuantity: number;
|
||||||
|
lowStockThreshold: number;
|
||||||
|
allowBackorder: boolean;
|
||||||
|
weight: number | null;
|
||||||
|
weightUnit: string;
|
||||||
|
length: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
dimensionUnit: string;
|
||||||
|
images: string[];
|
||||||
|
featuredImageUrl: string | null;
|
||||||
|
metaTitle: string | null;
|
||||||
|
metaDescription: string | null;
|
||||||
|
tags: string[];
|
||||||
|
isVisible: boolean;
|
||||||
|
isFeatured: boolean;
|
||||||
|
hasVariants: boolean;
|
||||||
|
variantAttributes: string[];
|
||||||
|
customFields: Record<string, any>;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
createdBy: string | null;
|
||||||
|
publishedAt: Date | null;
|
||||||
|
category?: { id: string; name: string; slug: string } | null;
|
||||||
|
variantCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductListQueryDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsEnum(ProductType)
|
||||||
|
@IsOptional()
|
||||||
|
productType?: ProductType;
|
||||||
|
|
||||||
|
@IsEnum(ProductStatus)
|
||||||
|
@IsOptional()
|
||||||
|
status?: ProductStatus;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isVisible?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isFeatured?: boolean;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
minPrice?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
maxPrice?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaginatedProductsDto {
|
||||||
|
items: ProductResponseDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Variant DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateVariantDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
sku?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
barcode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
attributes: Record<string, any>;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
price?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
costPrice?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
compareAtPrice?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
stockQuantity?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
lowStockThreshold?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsOptional()
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateVariantDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
sku?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
barcode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
price?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
costPrice?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
compareAtPrice?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
stockQuantity?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
lowStockThreshold?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsOptional()
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VariantResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
productId: string;
|
||||||
|
sku: string | null;
|
||||||
|
barcode: string | null;
|
||||||
|
name: string | null;
|
||||||
|
attributes: Record<string, any>;
|
||||||
|
price: number | null;
|
||||||
|
costPrice: number | null;
|
||||||
|
compareAtPrice: number | null;
|
||||||
|
stockQuantity: number;
|
||||||
|
lowStockThreshold: number | null;
|
||||||
|
weight: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
position: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Price DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreatePriceDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
variantId?: string;
|
||||||
|
|
||||||
|
@IsEnum(PriceType)
|
||||||
|
@IsOptional()
|
||||||
|
priceType?: PriceType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
compareAtAmount?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
@IsOptional()
|
||||||
|
billingPeriod?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
billingInterval?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
minQuantity?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
maxQuantity?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
validFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
validUntil?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
priority?: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePriceDto {
|
||||||
|
@IsEnum(PriceType)
|
||||||
|
@IsOptional()
|
||||||
|
priceType?: PriceType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
@IsOptional()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
amount?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
compareAtAmount?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
@IsOptional()
|
||||||
|
billingPeriod?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
billingInterval?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
minQuantity?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
maxQuantity?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
validFrom?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
validUntil?: string | null;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
priority?: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PriceResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
productId: string | null;
|
||||||
|
variantId: string | null;
|
||||||
|
priceType: PriceType;
|
||||||
|
currency: string;
|
||||||
|
amount: number;
|
||||||
|
compareAtAmount: number | null;
|
||||||
|
billingPeriod: string | null;
|
||||||
|
billingInterval: number | null;
|
||||||
|
minQuantity: number;
|
||||||
|
maxQuantity: number | null;
|
||||||
|
validFrom: Date | null;
|
||||||
|
validUntil: Date | null;
|
||||||
|
priority: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
77
src/modules/portfolio/entities/category.entity.ts
Normal file
77
src/modules/portfolio/entities/category.entity.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'portfolio', name: 'categories' })
|
||||||
|
export class CategoryEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
|
||||||
|
parentId: string | null;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ length: 120 })
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
position: number;
|
||||||
|
|
||||||
|
@Column({ name: 'image_url', length: 500, nullable: true })
|
||||||
|
imageUrl: string | null;
|
||||||
|
|
||||||
|
@Column({ length: 7, default: '#3B82F6' })
|
||||||
|
color: string;
|
||||||
|
|
||||||
|
@Column({ length: 50, nullable: true })
|
||||||
|
icon: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'meta_title', length: 200, nullable: true })
|
||||||
|
metaTitle: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'meta_description', type: 'text', nullable: true })
|
||||||
|
metaDescription: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'custom_fields', type: 'jsonb', default: {} })
|
||||||
|
customFields: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => CategoryEntity, (category) => category.children)
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: CategoryEntity | null;
|
||||||
|
|
||||||
|
@OneToMany(() => CategoryEntity, (category) => category.parent)
|
||||||
|
children: CategoryEntity[];
|
||||||
|
|
||||||
|
@OneToMany('ProductEntity', 'category')
|
||||||
|
products: any[];
|
||||||
|
}
|
||||||
4
src/modules/portfolio/entities/index.ts
Normal file
4
src/modules/portfolio/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './category.entity';
|
||||||
|
export * from './product.entity';
|
||||||
|
export * from './variant.entity';
|
||||||
|
export * from './price.entity';
|
||||||
92
src/modules/portfolio/entities/price.entity.ts
Normal file
92
src/modules/portfolio/entities/price.entity.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ProductEntity } from './product.entity';
|
||||||
|
import { VariantEntity } from './variant.entity';
|
||||||
|
|
||||||
|
export enum PriceType {
|
||||||
|
ONE_TIME = 'one_time',
|
||||||
|
RECURRING = 'recurring',
|
||||||
|
USAGE_BASED = 'usage_based',
|
||||||
|
TIERED = 'tiered',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'portfolio', name: 'prices' })
|
||||||
|
export class PriceEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'product_id', type: 'uuid', nullable: true })
|
||||||
|
productId: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'variant_id', type: 'uuid', nullable: true })
|
||||||
|
variantId: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'price_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: PriceType,
|
||||||
|
enumName: 'price_type',
|
||||||
|
default: PriceType.ONE_TIME,
|
||||||
|
})
|
||||||
|
priceType: PriceType;
|
||||||
|
|
||||||
|
@Column({ length: 3, default: 'USD' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2 })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'compare_at_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||||
|
compareAtAmount: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_period', length: 20, nullable: true })
|
||||||
|
billingPeriod: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_interval', type: 'int', nullable: true })
|
||||||
|
billingInterval: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'min_quantity', type: 'int', default: 1 })
|
||||||
|
minQuantity: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_quantity', type: 'int', nullable: true })
|
||||||
|
maxQuantity: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'valid_from', type: 'timestamptz', nullable: true })
|
||||||
|
validFrom: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
|
||||||
|
validUntil: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
priority: number;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => ProductEntity, (product) => product.prices)
|
||||||
|
@JoinColumn({ name: 'product_id' })
|
||||||
|
product: ProductEntity | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => VariantEntity, (variant) => variant.prices)
|
||||||
|
@JoinColumn({ name: 'variant_id' })
|
||||||
|
variant: VariantEntity | null;
|
||||||
|
}
|
||||||
171
src/modules/portfolio/entities/product.entity.ts
Normal file
171
src/modules/portfolio/entities/product.entity.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { CategoryEntity } from './category.entity';
|
||||||
|
|
||||||
|
export enum ProductType {
|
||||||
|
PHYSICAL = 'physical',
|
||||||
|
DIGITAL = 'digital',
|
||||||
|
SERVICE = 'service',
|
||||||
|
SUBSCRIPTION = 'subscription',
|
||||||
|
BUNDLE = 'bundle',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProductStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
DISCONTINUED = 'discontinued',
|
||||||
|
OUT_OF_STOCK = 'out_of_stock',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'portfolio', name: 'products' })
|
||||||
|
export class ProductEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'category_id', type: 'uuid', nullable: true })
|
||||||
|
categoryId: string | null;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ length: 280 })
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
sku: string | null;
|
||||||
|
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
barcode: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'short_description', length: 500, nullable: true })
|
||||||
|
shortDescription: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'product_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ProductType,
|
||||||
|
enumName: 'product_type',
|
||||||
|
default: ProductType.PHYSICAL,
|
||||||
|
})
|
||||||
|
productType: ProductType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ProductStatus,
|
||||||
|
enumName: 'product_status',
|
||||||
|
default: ProductStatus.DRAFT,
|
||||||
|
})
|
||||||
|
status: ProductStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'base_price', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
basePrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'cost_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||||
|
costPrice: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'compare_at_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||||
|
compareAtPrice: number | null;
|
||||||
|
|
||||||
|
@Column({ length: 3, default: 'USD' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@Column({ name: 'track_inventory', default: true })
|
||||||
|
trackInventory: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'stock_quantity', type: 'int', default: 0 })
|
||||||
|
stockQuantity: number;
|
||||||
|
|
||||||
|
@Column({ name: 'low_stock_threshold', type: 'int', default: 5 })
|
||||||
|
lowStockThreshold: number;
|
||||||
|
|
||||||
|
@Column({ name: 'allow_backorder', default: false })
|
||||||
|
allowBackorder: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 3, nullable: true })
|
||||||
|
weight: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'weight_unit', length: 10, default: 'kg' })
|
||||||
|
weightUnit: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
length: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
width: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
height: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'dimension_unit', length: 10, default: 'cm' })
|
||||||
|
dimensionUnit: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: [] })
|
||||||
|
images: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'featured_image_url', length: 500, nullable: true })
|
||||||
|
featuredImageUrl: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'meta_title', length: 200, nullable: true })
|
||||||
|
metaTitle: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'meta_description', type: 'text', nullable: true })
|
||||||
|
metaDescription: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: [] })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'is_visible', default: true })
|
||||||
|
isVisible: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_featured', default: false })
|
||||||
|
isFeatured: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'has_variants', default: false })
|
||||||
|
hasVariants: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'variant_attributes', type: 'jsonb', default: [] })
|
||||||
|
variantAttributes: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'custom_fields', type: 'jsonb', default: {} })
|
||||||
|
customFields: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'published_at', type: 'timestamptz', nullable: true })
|
||||||
|
publishedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => CategoryEntity, (category) => category.products)
|
||||||
|
@JoinColumn({ name: 'category_id' })
|
||||||
|
category: CategoryEntity | null;
|
||||||
|
|
||||||
|
@OneToMany('VariantEntity', 'product')
|
||||||
|
variants: any[];
|
||||||
|
|
||||||
|
@OneToMany('PriceEntity', 'product')
|
||||||
|
prices: any[];
|
||||||
|
}
|
||||||
78
src/modules/portfolio/entities/variant.entity.ts
Normal file
78
src/modules/portfolio/entities/variant.entity.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ProductEntity } from './product.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'portfolio', name: 'variants' })
|
||||||
|
export class VariantEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'product_id', type: 'uuid' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
sku: string | null;
|
||||||
|
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
barcode: string | null;
|
||||||
|
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
name: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
attributes: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||||
|
price: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'cost_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||||
|
costPrice: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'compare_at_price', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||||
|
compareAtPrice: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'stock_quantity', type: 'int', default: 0 })
|
||||||
|
stockQuantity: number;
|
||||||
|
|
||||||
|
@Column({ name: 'low_stock_threshold', type: 'int', nullable: true })
|
||||||
|
lowStockThreshold: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 3, nullable: true })
|
||||||
|
weight: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'image_url', length: 500, nullable: true })
|
||||||
|
imageUrl: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
position: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => ProductEntity, (product) => product.variants)
|
||||||
|
@JoinColumn({ name: 'product_id' })
|
||||||
|
product: ProductEntity;
|
||||||
|
|
||||||
|
@OneToMany('PriceEntity', 'variant')
|
||||||
|
prices: any[];
|
||||||
|
}
|
||||||
5
src/modules/portfolio/index.ts
Normal file
5
src/modules/portfolio/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './portfolio.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './dto';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
43
src/modules/portfolio/portfolio.module.ts
Normal file
43
src/modules/portfolio/portfolio.module.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CategoryEntity,
|
||||||
|
ProductEntity,
|
||||||
|
VariantEntity,
|
||||||
|
PriceEntity,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CategoriesService,
|
||||||
|
ProductsService,
|
||||||
|
} from './services';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CategoriesController,
|
||||||
|
ProductsController,
|
||||||
|
} from './controllers';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
CategoryEntity,
|
||||||
|
ProductEntity,
|
||||||
|
VariantEntity,
|
||||||
|
PriceEntity,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
CategoriesController,
|
||||||
|
ProductsController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CategoriesService,
|
||||||
|
ProductsService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
CategoriesService,
|
||||||
|
ProductsService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PortfolioModule {}
|
||||||
216
src/modules/portfolio/services/categories.service.ts
Normal file
216
src/modules/portfolio/services/categories.service.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
import { CategoryEntity } from '../entities';
|
||||||
|
import {
|
||||||
|
CreateCategoryDto,
|
||||||
|
UpdateCategoryDto,
|
||||||
|
CategoryResponseDto,
|
||||||
|
CategoryTreeNodeDto,
|
||||||
|
CategoryListQueryDto,
|
||||||
|
PaginatedCategoriesDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
private readonly logger = new Logger(CategoriesService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CategoryEntity)
|
||||||
|
private readonly categoryRepo: Repository<CategoryEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(tenantId: string, userId: string, dto: CreateCategoryDto): Promise<CategoryResponseDto> {
|
||||||
|
if (dto.parentId) {
|
||||||
|
const parent = await this.categoryRepo.findOne({
|
||||||
|
where: { id: dto.parentId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new BadRequestException('Parent category not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = this.categoryRepo.create({
|
||||||
|
tenantId,
|
||||||
|
parentId: dto.parentId || null,
|
||||||
|
name: dto.name,
|
||||||
|
slug: dto.slug,
|
||||||
|
description: dto.description,
|
||||||
|
position: dto.position ?? 0,
|
||||||
|
imageUrl: dto.imageUrl,
|
||||||
|
color: dto.color ?? '#3B82F6',
|
||||||
|
icon: dto.icon,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
metaTitle: dto.metaTitle,
|
||||||
|
metaDescription: dto.metaDescription,
|
||||||
|
customFields: dto.customFields ?? {},
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.categoryRepo.save(category);
|
||||||
|
this.logger.log(`Category created: ${saved.id} for tenant ${tenantId}`);
|
||||||
|
|
||||||
|
return this.toResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(tenantId: string, query: CategoryListQueryDto): Promise<PaginatedCategoriesDto> {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const limit = Math.min(query.limit || 20, 100);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const qb = this.categoryRepo
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.where('c.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('c.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (query.parentId !== undefined) {
|
||||||
|
if (query.parentId === null || query.parentId === '') {
|
||||||
|
qb.andWhere('c.parent_id IS NULL');
|
||||||
|
} else {
|
||||||
|
qb.andWhere('c.parent_id = :parentId', { parentId: query.parentId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
qb.andWhere('c.is_active = :isActive', { isActive: query.isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere('(c.name ILIKE :search OR c.slug ILIKE :search)', {
|
||||||
|
search: `%${query.search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.orderBy('c.position', 'ASC').addOrderBy('c.name', 'ASC').skip(skip).take(limit);
|
||||||
|
|
||||||
|
const [items, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((c) => this.toResponse(c)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(tenantId: string, categoryId: string): Promise<CategoryResponseDto> {
|
||||||
|
const category = await this.categoryRepo.findOne({
|
||||||
|
where: { id: categoryId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toResponse(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTree(tenantId: string): Promise<CategoryTreeNodeDto[]> {
|
||||||
|
const categories = await this.categoryRepo.find({
|
||||||
|
where: { tenantId, deletedAt: IsNull() },
|
||||||
|
order: { position: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.buildTree(categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tenantId: string, categoryId: string, dto: UpdateCategoryDto): Promise<CategoryResponseDto> {
|
||||||
|
const category = await this.categoryRepo.findOne({
|
||||||
|
where: { id: categoryId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.parentId !== undefined) {
|
||||||
|
if (dto.parentId === categoryId) {
|
||||||
|
throw new BadRequestException('Category cannot be its own parent');
|
||||||
|
}
|
||||||
|
if (dto.parentId) {
|
||||||
|
const parent = await this.categoryRepo.findOne({
|
||||||
|
where: { id: dto.parentId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new BadRequestException('Parent category not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(category, {
|
||||||
|
parentId: dto.parentId !== undefined ? dto.parentId : category.parentId,
|
||||||
|
name: dto.name ?? category.name,
|
||||||
|
slug: dto.slug ?? category.slug,
|
||||||
|
description: dto.description ?? category.description,
|
||||||
|
position: dto.position ?? category.position,
|
||||||
|
imageUrl: dto.imageUrl ?? category.imageUrl,
|
||||||
|
color: dto.color ?? category.color,
|
||||||
|
icon: dto.icon ?? category.icon,
|
||||||
|
isActive: dto.isActive ?? category.isActive,
|
||||||
|
metaTitle: dto.metaTitle ?? category.metaTitle,
|
||||||
|
metaDescription: dto.metaDescription ?? category.metaDescription,
|
||||||
|
customFields: dto.customFields ?? category.customFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.categoryRepo.save(category);
|
||||||
|
this.logger.log(`Category updated: ${saved.id}`);
|
||||||
|
|
||||||
|
return this.toResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, categoryId: string): Promise<void> {
|
||||||
|
const category = await this.categoryRepo.findOne({
|
||||||
|
where: { id: categoryId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChildren = await this.categoryRepo.count({
|
||||||
|
where: { parentId: categoryId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasChildren > 0) {
|
||||||
|
throw new BadRequestException('Cannot delete category with child categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
category.deletedAt = new Date();
|
||||||
|
await this.categoryRepo.save(category);
|
||||||
|
this.logger.log(`Category soft-deleted: ${categoryId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTree(categories: CategoryEntity[], parentId: string | null = null, depth = 0): CategoryTreeNodeDto[] {
|
||||||
|
return categories
|
||||||
|
.filter((c) => c.parentId === parentId)
|
||||||
|
.map((c) => ({
|
||||||
|
...this.toResponse(c),
|
||||||
|
depth,
|
||||||
|
children: this.buildTree(categories, c.id, depth + 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toResponse(category: CategoryEntity): CategoryResponseDto {
|
||||||
|
return {
|
||||||
|
id: category.id,
|
||||||
|
tenantId: category.tenantId,
|
||||||
|
parentId: category.parentId,
|
||||||
|
name: category.name,
|
||||||
|
slug: category.slug,
|
||||||
|
description: category.description,
|
||||||
|
position: category.position,
|
||||||
|
imageUrl: category.imageUrl,
|
||||||
|
color: category.color,
|
||||||
|
icon: category.icon,
|
||||||
|
isActive: category.isActive,
|
||||||
|
metaTitle: category.metaTitle,
|
||||||
|
metaDescription: category.metaDescription,
|
||||||
|
customFields: category.customFields,
|
||||||
|
createdAt: category.createdAt,
|
||||||
|
updatedAt: category.updatedAt,
|
||||||
|
createdBy: category.createdBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/modules/portfolio/services/index.ts
Normal file
2
src/modules/portfolio/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './categories.service';
|
||||||
|
export * from './products.service';
|
||||||
574
src/modules/portfolio/services/products.service.ts
Normal file
574
src/modules/portfolio/services/products.service.ts
Normal file
@ -0,0 +1,574 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, IsNull, In } from 'typeorm';
|
||||||
|
|
||||||
|
import { ProductEntity, ProductStatus, VariantEntity, PriceEntity } from '../entities';
|
||||||
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
UpdateProductStatusDto,
|
||||||
|
ProductResponseDto,
|
||||||
|
ProductListQueryDto,
|
||||||
|
PaginatedProductsDto,
|
||||||
|
CreateVariantDto,
|
||||||
|
UpdateVariantDto,
|
||||||
|
VariantResponseDto,
|
||||||
|
CreatePriceDto,
|
||||||
|
UpdatePriceDto,
|
||||||
|
PriceResponseDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductsService {
|
||||||
|
private readonly logger = new Logger(ProductsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ProductEntity)
|
||||||
|
private readonly productRepo: Repository<ProductEntity>,
|
||||||
|
@InjectRepository(VariantEntity)
|
||||||
|
private readonly variantRepo: Repository<VariantEntity>,
|
||||||
|
@InjectRepository(PriceEntity)
|
||||||
|
private readonly priceRepo: Repository<PriceEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Products
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async create(tenantId: string, userId: string, dto: CreateProductDto): Promise<ProductResponseDto> {
|
||||||
|
const product = this.productRepo.create({
|
||||||
|
tenantId,
|
||||||
|
categoryId: dto.categoryId || null,
|
||||||
|
name: dto.name,
|
||||||
|
slug: dto.slug,
|
||||||
|
sku: dto.sku,
|
||||||
|
barcode: dto.barcode,
|
||||||
|
description: dto.description,
|
||||||
|
shortDescription: dto.shortDescription,
|
||||||
|
productType: dto.productType,
|
||||||
|
status: dto.status ?? ProductStatus.DRAFT,
|
||||||
|
basePrice: dto.basePrice ?? 0,
|
||||||
|
costPrice: dto.costPrice,
|
||||||
|
compareAtPrice: dto.compareAtPrice,
|
||||||
|
currency: dto.currency ?? 'USD',
|
||||||
|
trackInventory: dto.trackInventory ?? true,
|
||||||
|
stockQuantity: dto.stockQuantity ?? 0,
|
||||||
|
lowStockThreshold: dto.lowStockThreshold ?? 5,
|
||||||
|
allowBackorder: dto.allowBackorder ?? false,
|
||||||
|
weight: dto.weight,
|
||||||
|
weightUnit: dto.weightUnit ?? 'kg',
|
||||||
|
length: dto.length,
|
||||||
|
width: dto.width,
|
||||||
|
height: dto.height,
|
||||||
|
dimensionUnit: dto.dimensionUnit ?? 'cm',
|
||||||
|
images: dto.images ?? [],
|
||||||
|
featuredImageUrl: dto.featuredImageUrl,
|
||||||
|
metaTitle: dto.metaTitle,
|
||||||
|
metaDescription: dto.metaDescription,
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
isVisible: dto.isVisible ?? true,
|
||||||
|
isFeatured: dto.isFeatured ?? false,
|
||||||
|
hasVariants: dto.hasVariants ?? false,
|
||||||
|
variantAttributes: dto.variantAttributes ?? [],
|
||||||
|
customFields: dto.customFields ?? {},
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.productRepo.save(product);
|
||||||
|
this.logger.log(`Product created: ${saved.id} for tenant ${tenantId}`);
|
||||||
|
|
||||||
|
return this.toProductResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(tenantId: string, query: ProductListQueryDto): Promise<PaginatedProductsDto> {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const limit = Math.min(query.limit || 20, 100);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const qb = this.productRepo
|
||||||
|
.createQueryBuilder('p')
|
||||||
|
.leftJoinAndSelect('p.category', 'category')
|
||||||
|
.where('p.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('p.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (query.categoryId) {
|
||||||
|
qb.andWhere('p.category_id = :categoryId', { categoryId: query.categoryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.productType) {
|
||||||
|
qb.andWhere('p.product_type = :productType', { productType: query.productType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
qb.andWhere('p.status = :status', { status: query.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isVisible !== undefined) {
|
||||||
|
qb.andWhere('p.is_visible = :isVisible', { isVisible: query.isVisible });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isFeatured !== undefined) {
|
||||||
|
qb.andWhere('p.is_featured = :isFeatured', { isFeatured: query.isFeatured });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.minPrice !== undefined) {
|
||||||
|
qb.andWhere('p.base_price >= :minPrice', { minPrice: query.minPrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.maxPrice !== undefined) {
|
||||||
|
qb.andWhere('p.base_price <= :maxPrice', { maxPrice: query.maxPrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(p.name ILIKE :search OR p.slug ILIKE :search OR p.sku ILIKE :search OR p.description ILIKE :search)',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.tags && query.tags.length > 0) {
|
||||||
|
qb.andWhere('p.tags ?| :tags', { tags: query.tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBy = query.sortBy || 'created_at';
|
||||||
|
const sortOrder = query.sortOrder || 'DESC';
|
||||||
|
qb.orderBy(`p.${sortBy}`, sortOrder).skip(skip).take(limit);
|
||||||
|
|
||||||
|
const [items, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((p) => this.toProductResponse(p)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(tenantId: string, productId: string): Promise<ProductResponseDto> {
|
||||||
|
const product = await this.productRepo.findOne({
|
||||||
|
where: { id: productId, tenantId, deletedAt: IsNull() },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantCount = await this.variantRepo.count({
|
||||||
|
where: { productId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...this.toProductResponse(product), variantCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tenantId: string, productId: string, dto: UpdateProductDto): Promise<ProductResponseDto> {
|
||||||
|
const product = await this.productRepo.findOne({
|
||||||
|
where: { id: productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(product, {
|
||||||
|
categoryId: dto.categoryId !== undefined ? dto.categoryId : product.categoryId,
|
||||||
|
name: dto.name ?? product.name,
|
||||||
|
slug: dto.slug ?? product.slug,
|
||||||
|
sku: dto.sku ?? product.sku,
|
||||||
|
barcode: dto.barcode ?? product.barcode,
|
||||||
|
description: dto.description ?? product.description,
|
||||||
|
shortDescription: dto.shortDescription ?? product.shortDescription,
|
||||||
|
productType: dto.productType ?? product.productType,
|
||||||
|
status: dto.status ?? product.status,
|
||||||
|
basePrice: dto.basePrice ?? product.basePrice,
|
||||||
|
costPrice: dto.costPrice ?? product.costPrice,
|
||||||
|
compareAtPrice: dto.compareAtPrice ?? product.compareAtPrice,
|
||||||
|
currency: dto.currency ?? product.currency,
|
||||||
|
trackInventory: dto.trackInventory ?? product.trackInventory,
|
||||||
|
stockQuantity: dto.stockQuantity ?? product.stockQuantity,
|
||||||
|
lowStockThreshold: dto.lowStockThreshold ?? product.lowStockThreshold,
|
||||||
|
allowBackorder: dto.allowBackorder ?? product.allowBackorder,
|
||||||
|
weight: dto.weight ?? product.weight,
|
||||||
|
weightUnit: dto.weightUnit ?? product.weightUnit,
|
||||||
|
length: dto.length ?? product.length,
|
||||||
|
width: dto.width ?? product.width,
|
||||||
|
height: dto.height ?? product.height,
|
||||||
|
dimensionUnit: dto.dimensionUnit ?? product.dimensionUnit,
|
||||||
|
images: dto.images ?? product.images,
|
||||||
|
featuredImageUrl: dto.featuredImageUrl ?? product.featuredImageUrl,
|
||||||
|
metaTitle: dto.metaTitle ?? product.metaTitle,
|
||||||
|
metaDescription: dto.metaDescription ?? product.metaDescription,
|
||||||
|
tags: dto.tags ?? product.tags,
|
||||||
|
isVisible: dto.isVisible ?? product.isVisible,
|
||||||
|
isFeatured: dto.isFeatured ?? product.isFeatured,
|
||||||
|
hasVariants: dto.hasVariants ?? product.hasVariants,
|
||||||
|
variantAttributes: dto.variantAttributes ?? product.variantAttributes,
|
||||||
|
customFields: dto.customFields ?? product.customFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.productRepo.save(product);
|
||||||
|
this.logger.log(`Product updated: ${saved.id}`);
|
||||||
|
|
||||||
|
return this.toProductResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(tenantId: string, productId: string, dto: UpdateProductStatusDto): Promise<ProductResponseDto> {
|
||||||
|
const product = await this.productRepo.findOne({
|
||||||
|
where: { id: productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
product.status = dto.status;
|
||||||
|
if (dto.status === ProductStatus.ACTIVE && !product.publishedAt) {
|
||||||
|
product.publishedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await this.productRepo.save(product);
|
||||||
|
this.logger.log(`Product status updated: ${saved.id} -> ${dto.status}`);
|
||||||
|
|
||||||
|
return this.toProductResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async duplicate(tenantId: string, userId: string, productId: string): Promise<ProductResponseDto> {
|
||||||
|
const product = await this.productRepo.findOne({
|
||||||
|
where: { id: productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicated = this.productRepo.create({
|
||||||
|
...product,
|
||||||
|
id: undefined,
|
||||||
|
name: `${product.name} (Copy)`,
|
||||||
|
slug: `${product.slug}-copy-${Date.now()}`,
|
||||||
|
sku: product.sku ? `${product.sku}-COPY` : null,
|
||||||
|
status: ProductStatus.DRAFT,
|
||||||
|
publishedAt: null,
|
||||||
|
createdBy: userId,
|
||||||
|
createdAt: undefined,
|
||||||
|
updatedAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.productRepo.save(duplicated);
|
||||||
|
this.logger.log(`Product duplicated: ${productId} -> ${saved.id}`);
|
||||||
|
|
||||||
|
return this.toProductResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, productId: string): Promise<void> {
|
||||||
|
const product = await this.productRepo.findOne({
|
||||||
|
where: { id: productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
product.deletedAt = new Date();
|
||||||
|
await this.productRepo.save(product);
|
||||||
|
this.logger.log(`Product soft-deleted: ${productId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Variants
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getVariants(tenantId: string, productId: string): Promise<VariantResponseDto[]> {
|
||||||
|
await this.verifyProductExists(tenantId, productId);
|
||||||
|
|
||||||
|
const variants = await this.variantRepo.find({
|
||||||
|
where: { productId, tenantId, deletedAt: IsNull() },
|
||||||
|
order: { position: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return variants.map((v) => this.toVariantResponse(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVariant(tenantId: string, productId: string, dto: CreateVariantDto): Promise<VariantResponseDto> {
|
||||||
|
await this.verifyProductExists(tenantId, productId);
|
||||||
|
|
||||||
|
const variant = this.variantRepo.create({
|
||||||
|
tenantId,
|
||||||
|
productId,
|
||||||
|
sku: dto.sku,
|
||||||
|
barcode: dto.barcode,
|
||||||
|
name: dto.name,
|
||||||
|
attributes: dto.attributes,
|
||||||
|
price: dto.price,
|
||||||
|
costPrice: dto.costPrice,
|
||||||
|
compareAtPrice: dto.compareAtPrice,
|
||||||
|
stockQuantity: dto.stockQuantity ?? 0,
|
||||||
|
lowStockThreshold: dto.lowStockThreshold,
|
||||||
|
weight: dto.weight,
|
||||||
|
imageUrl: dto.imageUrl,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
position: dto.position ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.variantRepo.save(variant);
|
||||||
|
this.logger.log(`Variant created: ${saved.id} for product ${productId}`);
|
||||||
|
|
||||||
|
return this.toVariantResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVariant(
|
||||||
|
tenantId: string,
|
||||||
|
productId: string,
|
||||||
|
variantId: string,
|
||||||
|
dto: UpdateVariantDto,
|
||||||
|
): Promise<VariantResponseDto> {
|
||||||
|
await this.verifyProductExists(tenantId, productId);
|
||||||
|
|
||||||
|
const variant = await this.variantRepo.findOne({
|
||||||
|
where: { id: variantId, productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!variant) {
|
||||||
|
throw new NotFoundException('Variant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(variant, {
|
||||||
|
sku: dto.sku ?? variant.sku,
|
||||||
|
barcode: dto.barcode ?? variant.barcode,
|
||||||
|
name: dto.name ?? variant.name,
|
||||||
|
attributes: dto.attributes ?? variant.attributes,
|
||||||
|
price: dto.price ?? variant.price,
|
||||||
|
costPrice: dto.costPrice ?? variant.costPrice,
|
||||||
|
compareAtPrice: dto.compareAtPrice ?? variant.compareAtPrice,
|
||||||
|
stockQuantity: dto.stockQuantity ?? variant.stockQuantity,
|
||||||
|
lowStockThreshold: dto.lowStockThreshold ?? variant.lowStockThreshold,
|
||||||
|
weight: dto.weight ?? variant.weight,
|
||||||
|
imageUrl: dto.imageUrl ?? variant.imageUrl,
|
||||||
|
isActive: dto.isActive ?? variant.isActive,
|
||||||
|
position: dto.position ?? variant.position,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.variantRepo.save(variant);
|
||||||
|
this.logger.log(`Variant updated: ${saved.id}`);
|
||||||
|
|
||||||
|
return this.toVariantResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeVariant(tenantId: string, productId: string, variantId: string): Promise<void> {
|
||||||
|
await this.verifyProductExists(tenantId, productId);
|
||||||
|
|
||||||
|
const variant = await this.variantRepo.findOne({
|
||||||
|
where: { id: variantId, productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!variant) {
|
||||||
|
throw new NotFoundException('Variant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
variant.deletedAt = new Date();
|
||||||
|
await this.variantRepo.save(variant);
|
||||||
|
this.logger.log(`Variant soft-deleted: ${variantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Prices
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getPrices(tenantId: string, productId: string): Promise<PriceResponseDto[]> {
|
||||||
|
await this.verifyProductExists(tenantId, productId);
|
||||||
|
|
||||||
|
const prices = await this.priceRepo.find({
|
||||||
|
where: { productId, tenantId, deletedAt: IsNull() },
|
||||||
|
order: { currency: 'ASC', priority: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return prices.map((p) => this.toPriceResponse(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPrice(tenantId: string, productId: string, dto: CreatePriceDto): Promise<PriceResponseDto> {
|
||||||
|
await this.verifyProductExists(tenantId, productId);
|
||||||
|
|
||||||
|
const price = this.priceRepo.create({
|
||||||
|
tenantId,
|
||||||
|
productId,
|
||||||
|
variantId: dto.variantId || null,
|
||||||
|
priceType: dto.priceType,
|
||||||
|
currency: dto.currency,
|
||||||
|
amount: dto.amount,
|
||||||
|
compareAtAmount: dto.compareAtAmount,
|
||||||
|
billingPeriod: dto.billingPeriod,
|
||||||
|
billingInterval: dto.billingInterval,
|
||||||
|
minQuantity: dto.minQuantity ?? 1,
|
||||||
|
maxQuantity: dto.maxQuantity,
|
||||||
|
validFrom: dto.validFrom ? new Date(dto.validFrom) : null,
|
||||||
|
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
|
||||||
|
priority: dto.priority ?? 0,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.priceRepo.save(price);
|
||||||
|
this.logger.log(`Price created: ${saved.id} for product ${productId}`);
|
||||||
|
|
||||||
|
return this.toPriceResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePrice(
|
||||||
|
tenantId: string,
|
||||||
|
productId: string,
|
||||||
|
priceId: string,
|
||||||
|
dto: UpdatePriceDto,
|
||||||
|
): Promise<PriceResponseDto> {
|
||||||
|
await this.verifyProductExists(tenantId, productId);
|
||||||
|
|
||||||
|
const price = await this.priceRepo.findOne({
|
||||||
|
where: { id: priceId, productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!price) {
|
||||||
|
throw new NotFoundException('Price not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(price, {
|
||||||
|
priceType: dto.priceType ?? price.priceType,
|
||||||
|
currency: dto.currency ?? price.currency,
|
||||||
|
amount: dto.amount ?? price.amount,
|
||||||
|
compareAtAmount: dto.compareAtAmount ?? price.compareAtAmount,
|
||||||
|
billingPeriod: dto.billingPeriod ?? price.billingPeriod,
|
||||||
|
billingInterval: dto.billingInterval ?? price.billingInterval,
|
||||||
|
minQuantity: dto.minQuantity ?? price.minQuantity,
|
||||||
|
maxQuantity: dto.maxQuantity ?? price.maxQuantity,
|
||||||
|
validFrom: dto.validFrom !== undefined ? (dto.validFrom ? new Date(dto.validFrom) : null) : price.validFrom,
|
||||||
|
validUntil: dto.validUntil !== undefined ? (dto.validUntil ? new Date(dto.validUntil) : null) : price.validUntil,
|
||||||
|
priority: dto.priority ?? price.priority,
|
||||||
|
isActive: dto.isActive ?? price.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.priceRepo.save(price);
|
||||||
|
this.logger.log(`Price updated: ${saved.id}`);
|
||||||
|
|
||||||
|
return this.toPriceResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePrice(tenantId: string, productId: string, priceId: string): Promise<void> {
|
||||||
|
await this.verifyProductExists(tenantId, productId);
|
||||||
|
|
||||||
|
const price = await this.priceRepo.findOne({
|
||||||
|
where: { id: priceId, productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!price) {
|
||||||
|
throw new NotFoundException('Price not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
price.deletedAt = new Date();
|
||||||
|
await this.priceRepo.save(price);
|
||||||
|
this.logger.log(`Price soft-deleted: ${priceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async verifyProductExists(tenantId: string, productId: string): Promise<void> {
|
||||||
|
const exists = await this.productRepo.count({
|
||||||
|
where: { id: productId, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
if (!exists) {
|
||||||
|
throw new NotFoundException('Product not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toProductResponse(product: ProductEntity): ProductResponseDto {
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
tenantId: product.tenantId,
|
||||||
|
categoryId: product.categoryId,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
sku: product.sku,
|
||||||
|
barcode: product.barcode,
|
||||||
|
description: product.description,
|
||||||
|
shortDescription: product.shortDescription,
|
||||||
|
productType: product.productType,
|
||||||
|
status: product.status,
|
||||||
|
basePrice: Number(product.basePrice),
|
||||||
|
costPrice: product.costPrice ? Number(product.costPrice) : null,
|
||||||
|
compareAtPrice: product.compareAtPrice ? Number(product.compareAtPrice) : null,
|
||||||
|
currency: product.currency,
|
||||||
|
trackInventory: product.trackInventory,
|
||||||
|
stockQuantity: product.stockQuantity,
|
||||||
|
lowStockThreshold: product.lowStockThreshold,
|
||||||
|
allowBackorder: product.allowBackorder,
|
||||||
|
weight: product.weight ? Number(product.weight) : null,
|
||||||
|
weightUnit: product.weightUnit,
|
||||||
|
length: product.length ? Number(product.length) : null,
|
||||||
|
width: product.width ? Number(product.width) : null,
|
||||||
|
height: product.height ? Number(product.height) : null,
|
||||||
|
dimensionUnit: product.dimensionUnit,
|
||||||
|
images: product.images,
|
||||||
|
featuredImageUrl: product.featuredImageUrl,
|
||||||
|
metaTitle: product.metaTitle,
|
||||||
|
metaDescription: product.metaDescription,
|
||||||
|
tags: product.tags,
|
||||||
|
isVisible: product.isVisible,
|
||||||
|
isFeatured: product.isFeatured,
|
||||||
|
hasVariants: product.hasVariants,
|
||||||
|
variantAttributes: product.variantAttributes,
|
||||||
|
customFields: product.customFields,
|
||||||
|
createdAt: product.createdAt,
|
||||||
|
updatedAt: product.updatedAt,
|
||||||
|
createdBy: product.createdBy,
|
||||||
|
publishedAt: product.publishedAt,
|
||||||
|
category: product.category
|
||||||
|
? { id: product.category.id, name: product.category.name, slug: product.category.slug }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toVariantResponse(variant: VariantEntity): VariantResponseDto {
|
||||||
|
return {
|
||||||
|
id: variant.id,
|
||||||
|
tenantId: variant.tenantId,
|
||||||
|
productId: variant.productId,
|
||||||
|
sku: variant.sku,
|
||||||
|
barcode: variant.barcode,
|
||||||
|
name: variant.name,
|
||||||
|
attributes: variant.attributes,
|
||||||
|
price: variant.price ? Number(variant.price) : null,
|
||||||
|
costPrice: variant.costPrice ? Number(variant.costPrice) : null,
|
||||||
|
compareAtPrice: variant.compareAtPrice ? Number(variant.compareAtPrice) : null,
|
||||||
|
stockQuantity: variant.stockQuantity,
|
||||||
|
lowStockThreshold: variant.lowStockThreshold,
|
||||||
|
weight: variant.weight ? Number(variant.weight) : null,
|
||||||
|
imageUrl: variant.imageUrl,
|
||||||
|
isActive: variant.isActive,
|
||||||
|
position: variant.position,
|
||||||
|
createdAt: variant.createdAt,
|
||||||
|
updatedAt: variant.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPriceResponse(price: PriceEntity): PriceResponseDto {
|
||||||
|
return {
|
||||||
|
id: price.id,
|
||||||
|
tenantId: price.tenantId,
|
||||||
|
productId: price.productId,
|
||||||
|
variantId: price.variantId,
|
||||||
|
priceType: price.priceType,
|
||||||
|
currency: price.currency,
|
||||||
|
amount: Number(price.amount),
|
||||||
|
compareAtAmount: price.compareAtAmount ? Number(price.compareAtAmount) : null,
|
||||||
|
billingPeriod: price.billingPeriod,
|
||||||
|
billingInterval: price.billingInterval,
|
||||||
|
minQuantity: price.minQuantity,
|
||||||
|
maxQuantity: price.maxQuantity,
|
||||||
|
validFrom: price.validFrom,
|
||||||
|
validUntil: price.validUntil,
|
||||||
|
priority: price.priority,
|
||||||
|
isActive: price.isActive,
|
||||||
|
createdAt: price.createdAt,
|
||||||
|
updatedAt: price.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user