[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:
Adrian Flores Cortes 2026-01-25 05:41:52 -06:00
parent a2a1fd3d3b
commit 2921ca9e83
17 changed files with 2447 additions and 0 deletions

View File

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

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

View File

@ -0,0 +1,2 @@
export * from './categories.controller';
export * from './products.controller';

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

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

View File

@ -0,0 +1,2 @@
export * from './category.dto';
export * from './product.dto';

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

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

View File

@ -0,0 +1,4 @@
export * from './category.entity';
export * from './product.entity';
export * from './variant.entity';
export * from './price.entity';

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

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

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

View File

@ -0,0 +1,5 @@
export * from './portfolio.module';
export * from './entities';
export * from './dto';
export * from './services';
export * from './controllers';

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

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

View File

@ -0,0 +1,2 @@
export * from './categories.service';
export * from './products.service';

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