From 2921ca9e834e9b6da173ee3e1e8b53c6556a6047 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 05:41:52 -0600 Subject: [PATCH] [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 --- src/app.module.ts | 2 + .../controllers/categories.controller.ts | 83 ++ src/modules/portfolio/controllers/index.ts | 2 + .../controllers/products.controller.ts | 188 +++++ src/modules/portfolio/dto/category.dto.ts | 177 +++++ src/modules/portfolio/dto/index.ts | 2 + src/modules/portfolio/dto/product.dto.ts | 731 ++++++++++++++++++ .../portfolio/entities/category.entity.ts | 77 ++ src/modules/portfolio/entities/index.ts | 4 + .../portfolio/entities/price.entity.ts | 92 +++ .../portfolio/entities/product.entity.ts | 171 ++++ .../portfolio/entities/variant.entity.ts | 78 ++ src/modules/portfolio/index.ts | 5 + src/modules/portfolio/portfolio.module.ts | 43 ++ .../portfolio/services/categories.service.ts | 216 ++++++ src/modules/portfolio/services/index.ts | 2 + .../portfolio/services/products.service.ts | 574 ++++++++++++++ 17 files changed, 2447 insertions(+) create mode 100644 src/modules/portfolio/controllers/categories.controller.ts create mode 100644 src/modules/portfolio/controllers/index.ts create mode 100644 src/modules/portfolio/controllers/products.controller.ts create mode 100644 src/modules/portfolio/dto/category.dto.ts create mode 100644 src/modules/portfolio/dto/index.ts create mode 100644 src/modules/portfolio/dto/product.dto.ts create mode 100644 src/modules/portfolio/entities/category.entity.ts create mode 100644 src/modules/portfolio/entities/index.ts create mode 100644 src/modules/portfolio/entities/price.entity.ts create mode 100644 src/modules/portfolio/entities/product.entity.ts create mode 100644 src/modules/portfolio/entities/variant.entity.ts create mode 100644 src/modules/portfolio/index.ts create mode 100644 src/modules/portfolio/portfolio.module.ts create mode 100644 src/modules/portfolio/services/categories.service.ts create mode 100644 src/modules/portfolio/services/index.ts create mode 100644 src/modules/portfolio/services/products.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 851bcbd..61067bf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/modules/portfolio/controllers/categories.controller.ts b/src/modules/portfolio/controllers/categories.controller.ts new file mode 100644 index 0000000..7c31ea1 --- /dev/null +++ b/src/modules/portfolio/controllers/categories.controller.ts @@ -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 { + return this.categoriesService.findAll(user.tenant_id, query); + } + + @Get('tree') + async getTree(@CurrentUser() user: RequestUser): Promise { + return this.categoriesService.getTree(user.tenant_id); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.categoriesService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateCategoryDto, + ): Promise { + 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 { + 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' }; + } +} diff --git a/src/modules/portfolio/controllers/index.ts b/src/modules/portfolio/controllers/index.ts new file mode 100644 index 0000000..ae36666 --- /dev/null +++ b/src/modules/portfolio/controllers/index.ts @@ -0,0 +1,2 @@ +export * from './categories.controller'; +export * from './products.controller'; diff --git a/src/modules/portfolio/controllers/products.controller.ts b/src/modules/portfolio/controllers/products.controller.ts new file mode 100644 index 0000000..e02bcf2 --- /dev/null +++ b/src/modules/portfolio/controllers/products.controller.ts @@ -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 { + return this.productsService.findAll(user.tenant_id, query); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.productsService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateProductDto, + ): Promise { + 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 { + 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 { + return this.productsService.updateStatus(user.tenant_id, id, dto); + } + + @Post(':id/duplicate') + async duplicate( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' }; + } +} diff --git a/src/modules/portfolio/dto/category.dto.ts b/src/modules/portfolio/dto/category.dto.ts new file mode 100644 index 0000000..adddce6 --- /dev/null +++ b/src/modules/portfolio/dto/category.dto.ts @@ -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; +} + +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; +} + +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; + 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; +} diff --git a/src/modules/portfolio/dto/index.ts b/src/modules/portfolio/dto/index.ts new file mode 100644 index 0000000..befa455 --- /dev/null +++ b/src/modules/portfolio/dto/index.ts @@ -0,0 +1,2 @@ +export * from './category.dto'; +export * from './product.dto'; diff --git a/src/modules/portfolio/dto/product.dto.ts b/src/modules/portfolio/dto/product.dto.ts new file mode 100644 index 0000000..d78114c --- /dev/null +++ b/src/modules/portfolio/dto/product.dto.ts @@ -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; +} + +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; +} + +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; + 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; + + @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; + + @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; + 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; +} diff --git a/src/modules/portfolio/entities/category.entity.ts b/src/modules/portfolio/entities/category.entity.ts new file mode 100644 index 0000000..019e2a9 --- /dev/null +++ b/src/modules/portfolio/entities/category.entity.ts @@ -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; + + @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[]; +} diff --git a/src/modules/portfolio/entities/index.ts b/src/modules/portfolio/entities/index.ts new file mode 100644 index 0000000..38afaf1 --- /dev/null +++ b/src/modules/portfolio/entities/index.ts @@ -0,0 +1,4 @@ +export * from './category.entity'; +export * from './product.entity'; +export * from './variant.entity'; +export * from './price.entity'; diff --git a/src/modules/portfolio/entities/price.entity.ts b/src/modules/portfolio/entities/price.entity.ts new file mode 100644 index 0000000..65d361b --- /dev/null +++ b/src/modules/portfolio/entities/price.entity.ts @@ -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; +} diff --git a/src/modules/portfolio/entities/product.entity.ts b/src/modules/portfolio/entities/product.entity.ts new file mode 100644 index 0000000..093712c --- /dev/null +++ b/src/modules/portfolio/entities/product.entity.ts @@ -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; + + @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[]; +} diff --git a/src/modules/portfolio/entities/variant.entity.ts b/src/modules/portfolio/entities/variant.entity.ts new file mode 100644 index 0000000..1788752 --- /dev/null +++ b/src/modules/portfolio/entities/variant.entity.ts @@ -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; + + @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[]; +} diff --git a/src/modules/portfolio/index.ts b/src/modules/portfolio/index.ts new file mode 100644 index 0000000..16276ef --- /dev/null +++ b/src/modules/portfolio/index.ts @@ -0,0 +1,5 @@ +export * from './portfolio.module'; +export * from './entities'; +export * from './dto'; +export * from './services'; +export * from './controllers'; diff --git a/src/modules/portfolio/portfolio.module.ts b/src/modules/portfolio/portfolio.module.ts new file mode 100644 index 0000000..993da1e --- /dev/null +++ b/src/modules/portfolio/portfolio.module.ts @@ -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 {} diff --git a/src/modules/portfolio/services/categories.service.ts b/src/modules/portfolio/services/categories.service.ts new file mode 100644 index 0000000..6c75ed3 --- /dev/null +++ b/src/modules/portfolio/services/categories.service.ts @@ -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, + ) {} + + async create(tenantId: string, userId: string, dto: CreateCategoryDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/portfolio/services/index.ts b/src/modules/portfolio/services/index.ts new file mode 100644 index 0000000..b310550 --- /dev/null +++ b/src/modules/portfolio/services/index.ts @@ -0,0 +1,2 @@ +export * from './categories.service'; +export * from './products.service'; diff --git a/src/modules/portfolio/services/products.service.ts b/src/modules/portfolio/services/products.service.ts new file mode 100644 index 0000000..1b5643a --- /dev/null +++ b/src/modules/portfolio/services/products.service.ts @@ -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, + @InjectRepository(VariantEntity) + private readonly variantRepo: Repository, + @InjectRepository(PriceEntity) + private readonly priceRepo: Repository, + ) {} + + // ============================================ + // Products + // ============================================ + + async create(tenantId: string, userId: string, dto: CreateProductDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +}