[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 { SalesModule } from '@modules/sales/sales.module';
|
||||
import { CommissionsModule } from '@modules/commissions/commissions.module';
|
||||
import { PortfolioModule } from '@modules/portfolio/portfolio.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -92,6 +93,7 @@ import { CommissionsModule } from '@modules/commissions/commissions.module';
|
||||
ReportsModule,
|
||||
SalesModule,
|
||||
CommissionsModule,
|
||||
PortfolioModule,
|
||||
],
|
||||
})
|
||||
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