diff --git a/src/config/aws.config.ts b/src/config/aws.config.ts new file mode 100644 index 0000000..103a5d1 --- /dev/null +++ b/src/config/aws.config.ts @@ -0,0 +1,9 @@ +// aws.config.ts +export default () => ({ + aws: { + region: process.env.AWS_REGION, + bucket: process.env.AWS_S3_BUCKET, + accessKeyId: process.env.AWS_ACCESS_KEY, + secretAccessKey: process.env.AWS_SECRET_KEY, + }, +}); \ No newline at end of file diff --git a/src/inventory/inventory.module.ts b/src/inventory/inventory.module.ts index 72bca5a..e8eac55 100644 --- a/src/inventory/inventory.module.ts +++ b/src/inventory/inventory.module.ts @@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { Listing } from '../listing/entities/listing.entity'; +import { ListingVariant } from '../listing/entities/listing-variant.entity'; import { Product } from '../entities/product.entity'; import { InventoryHistory } from './inventory-history.entity'; import { InventoryService } from './inventory.service'; @@ -9,7 +10,7 @@ import { InventoryController } from './inventory.controller'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [ + imports: [ListingVariant, TypeOrmModule.forFeature([Listing, Product, InventoryHistory]), forwardRef(() => NotificationsModule), EventEmitterModule.forRoot(), diff --git a/src/inventory/inventory.service.ts b/src/inventory/inventory.service.ts index f0e7767..147e60d 100644 --- a/src/inventory/inventory.service.ts +++ b/src/inventory/inventory.service.ts @@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, EntityManager } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Listing } from '../listing/entities/listing.entity'; +import { ListingVariant } from '../listing/entities/listing-variant.entity'; import { InventoryHistory, InventoryChangeType, @@ -24,33 +25,124 @@ export class InventoryService { @InjectRepository(Listing) private readonly listingRepo: Repository, @InjectRepository(InventoryHistory) - private readonly historyRepo: Repository, + private readonly hListingVariant) + private readonly variantRepo: Repository, + @InjectRepository(istoryRepo: Repository, @InjectRepository(Product) private readonly productRepo: Repository, @Inject(forwardRef(() => NotificationsService)) private readonly notificationsService: NotificationsService, private readonly dataSource: DataSource, - private readonly eventEmitter: EventEmitter2, - ) {} + private async syncListingAggregate(listing: Listing, manager: EntityManager) { + const variants = await manager.find(ListingVariant, { + where: { listingId: listing.id }, + }); - async adjustInventory( - listingId: string, + if (variants.length > 0) { + listing.quantity = variants.reduce((sum, v) => sum + (v.quantity ?? 0), 0); + listing.reserved = variants.reduce((sum, v) => sum + (v.reserved ?? 0), 0); + listing.available = variants.reduce((sum, v) => sum + (v.available ?? 0), 0); + listing.price = Math.min(...variants.map((v) => Number(v.price))); + listing.currency = variants[0].currency; + } else { + listing.available = listing.quantity - listing.reserved; + } + + await manager.save(listing); + } + + async adjustVariantInventory( + variantId: string, userId: string, change: number, type: InventoryChangeType, note?: string, ) { return this.dataSource.transaction(async (manager) => { + const variant = await manager.findOne(ListingVariant, { + where: { id: variantId }, + }); + + if (!variant) { + throw new NotFoundException('Variant not found'); + } + + variant.quantity += change; + variant.available = variant.quantity - variant.reserved; + if (variant.available < 0) { + throw new BadRequestException('Insufficient inventory for variant'); + } + + await manager.save(variant); const listing = await manager.findOne(Listing, { - where: { id: listingId }, + where: { id: variant.listingId }, }); - if (!listing) throw new NotFoundException('Listing not found'); - listing.quantity += change; - listing.available = listing.quantity - listing.reserved; - if (listing.available < 0) - throw new BadRequestException('Insufficient inventory'); - await manager.save(listing); + if (listing) { + await this.syncListingAggregate(listing, manager); + } + const history = manager.create(InventoryHistory, { + listingId: variant.listingId, + userId, + change, + type, + note, + }); + await manager.save(history); + + if (variant.available <= 5 && listing) { + await this.notifyLowStock(listing); + } + + return variant; + }); + } + + private readonly eventEmitter: EventEmitter2, + ) {} + + async adjustInventory( + listingId: string, + userlet listing: Listing | null = null; + let variant: ListingVariant | null = null; + + if ('variantId' in item && item.variantId) { + variant = await manager.findOne(ListingVariant, { + where: { id: item.variantId }, + }); + if (!variant) { + throw new NotFoundException(`Variant ${item.variantId} not found`); + } + listing = await manager.findOne(Listing, { + where: { id: variant.listingId }, + }); + } else { + listing = await manager.findOne(Listing, { + where: { id: item.productId }, + }); + } + + if (!listing) { + throw new NotFoundException(`Listing ${item.productId} not found`); + } + + const available = variant ? variant.available : listing.available; + if (available < item.quantity) { + throw new BadRequestException( + `Not enough inventory for ${item.productName}. Available: ${available}, Requested: ${item.quantity}`, + ); + } + + if (variant) { + variant.reserved += item.quantity; + variant.available = variant.quantity - variant.reserved; + await manager.save(variant); + await this.syncListingAggregate(listing, manager); + } else { + listing.reserved += item.quantity; + listing.available = listing.quantity - listing.reserved; + await manager.save(listing); + }(InventoryHistory, { listingId, userId, change, diff --git a/src/listing/dto/create-listing.dto.ts b/src/listing/dto/create-listing.dto.ts index fcb8720..d9a9e1c 100644 --- a/src/listing/dto/create-listing.dto.ts +++ b/src/listing/dto/create-listing.dto.ts @@ -1,3 +1,4 @@ +import { ListingVariantDto } from './listing-variant.dto'; import { Type } from 'class-transformer'; import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; @@ -24,4 +25,5 @@ export class CreateListingDto { location: string; expiresAt?: Date; + variants?: ListingVariantDto[]; } diff --git a/src/listing/dto/listing-variant.dto.ts b/src/listing/dto/listing-variant.dto.ts new file mode 100644 index 0000000..ec1e977 --- /dev/null +++ b/src/listing/dto/listing-variant.dto.ts @@ -0,0 +1,8 @@ +export class ListingVariantDto { + sku?: string; + attributes?: Record; + price: number; + currency?: string; + quantity?: number; + reserved?: number; +} diff --git a/src/listing/entities/listing-variant.entity.ts b/src/listing/entities/listing-variant.entity.ts new file mode 100644 index 0000000..a43d465 --- /dev/null +++ b/src/listing/entities/listing-variant.entity.ts @@ -0,0 +1,58 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; +import { Listing } from './listing.entity'; + +@Entity('listing_variants') +export class ListingVariant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + @Index() + listingId: string; + + @ManyToOne(() => Listing, (listing) => listing.variants, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'listingId' }) + listing: Listing; + + @Column({ type: 'varchar', length: 255, nullable: true }) + sku?: string; + + @Column({ type: 'jsonb', nullable: true }) + attributes?: Record; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + price: number; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + @Column({ type: 'int', default: 1 }) + quantity: number; + + @Column({ type: 'int', default: 0 }) + reserved: number; + + @Column({ type: 'int', default: 1 }) + available: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; +} diff --git a/src/listing/entities/listing.entity.ts b/src/listing/entities/listing.entity.ts index 98dd5aa..375eb18 100644 --- a/src/listing/entities/listing.entity.ts +++ b/src/listing/entities/listing.entity.ts @@ -1,4 +1,5 @@ import { Users } from '../../users/users.entity'; +import { ListingVariant } from './listing-variant.entity'; import { Entity, PrimaryGeneratedColumn, @@ -9,6 +10,7 @@ import { UpdateDateColumn, DeleteDateColumn, ManyToMany, + OneToMany, Index, } from 'typeorm'; @@ -82,9 +84,50 @@ export class Listing { @ManyToMany(() => Users, (user) => user.favoriteListings) favoritedBy: Users[]; + @OneToMany(() => ListingVariant, (variant) => variant.listing, { + cascade: true, + eager: true, + }) + variants?: ListingVariant[]; + @Column({ type: 'int', default: 0 }) views: number; + get aggregatedQuantity(): number { + if (!this.variants || this.variants.length === 0) { + return this.quantity; + } + return this.variants.reduce((sum, variant) => sum + variant.quantity, 0); + } + + get aggregatedReserved(): number { + if (!this.variants || this.variants.length === 0) { + return this.reserved; + } + return this.variants.reduce((sum, variant) => sum + variant.reserved, 0); + } + + get aggregatedAvailable(): number { + if (!this.variants || this.variants.length === 0) { + return this.available; + } + return this.variants.reduce((sum, variant) => sum + variant.available, 0); + } + + get minVariantPrice(): number { + if (!this.variants || this.variants.length === 0) { + return Number(this.price); + } + return Math.min(...this.variants.map((variant) => Number(variant.price))); + } + + get variantCurrency(): string { + if (!this.variants || this.variants.length === 0) { + return this.currency; + } + return this.variants[0].currency; + } + @Column({ default: false }) isFeatured: boolean; diff --git a/src/listing/listing.module.ts b/src/listing/listing.module.ts index 97c6d95..d050298 100644 --- a/src/listing/listing.module.ts +++ b/src/listing/listing.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { Listing } from './entities/listing.entity'; +import { ListingVariant } from './entities/listing-variant.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ListingsController } from './listing.controller'; import { ListingsService } from './listing.service'; @@ -7,7 +8,7 @@ import { ConfigModule } from '@nestjs/config'; import { SearchModule } from '../search/search.module'; @Module({ - imports: [TypeOrmModule.forFeature([Listing]), ConfigModule, SearchModule], + imports: [TypeOrmModule.forFeature([Listing, ListingVariant]), ConfigModule, SearchModule], controllers: [ListingsController], providers: [ListingsService], exports: [ListingsService], diff --git a/src/listing/listing.service.ts b/src/listing/listing.service.ts index 9a14a3e..e36a68a 100644 --- a/src/listing/listing.service.ts +++ b/src/listing/listing.service.ts @@ -5,6 +5,7 @@ import { createReadStream } from 'node:fs'; import { CreateListingDto } from './dto/create-listing.dto'; import { UpdateListingDto } from './dto/update-listing.dto'; import { Listing } from './entities/listing.entity'; +import { ListingVariant } from './entities/listing-variant.entity'; import { ConfigService } from '@nestjs/config'; import { SearchSyncService } from '../search/search-sync.service'; import { parse } from 'csv-parse'; @@ -37,28 +38,102 @@ export class ListingsService { private readonly searchSyncService: SearchSyncService, ) {} + private buildVariant(dto: Partial): ListingVariant { + const variant = new ListingVariant(); + variant.sku = dto.sku; + variant.attributes = dto.attributes; + variant.price = dto.price ?? 0; + variant.currency = dto.currency ?? 'USD'; + variant.quantity = dto.quantity ?? 1; + variant.reserved = dto.reserved ?? 0; + variant.available = variant.quantity - variant.reserved; + if (variant.available < 0) { + variant.available = 0; + } + return variant; + } + + private aggregateListing(listing: Listing): Listing { + if (listing.variants && listing.variants.length > 0) { + const totalQuantity = listing.variants.reduce( + (sum, variant) => sum + (variant.quantity ?? 0), + 0, + ); + const totalReserved = listing.variants.reduce( + (sum, variant) => sum + (variant.reserved ?? 0), + 0, + ); + const totalAvailable = listing.variants.reduce( + (sum, variant) => sum + (variant.available ?? 0), + 0, + ); + const minPrice = Math.min( + ...listing.variants.map((variant) => Number(variant.price)), + ); + + listing.quantity = totalQuantity; + listing.reserved = totalReserved; + listing.available = totalAvailable; + listing.price = minPrice; + listing.currency = listing.variants[0].currency || 'USD'; + } else { + // preserve fallback root-level values, and compute available + listing.available = Math.max(0, listing.quantity - listing.reserved); + } + return listing; + } + + private prepareVariants(dto: CreateListingDto | UpdateListingDto): ListingVariant[] { + if (dto.variants && dto.variants.length > 0) { + return dto.variants.map((variantData) => + this.buildVariant({ + price: variantData.price, + currency: variantData.currency, + quantity: variantData.quantity ?? 1, + reserved: variantData.reserved ?? 0, + sku: variantData.sku, + attributes: variantData.attributes, + }), + ); + } + + // fallback single-variant behavior for legacy payloads + if (dto.price !== undefined) { + return [ + this.buildVariant({ + price: dto.price, + currency: dto.currency ?? 'USD', + quantity: dto.quantity ?? 1, + reserved: dto.reserved ?? 0, + }), + ]; + } + + return []; + } + async create(dto: CreateListingDto, userId: string) { - const expiryDays = this.configService.get( - 'LISTING_EXPIRY_DAYS', - 30, - ); + const expiryDays = this.configService.get('LISTING_EXPIRY_DAYS', 30); const now = new Date(); - const expiresAt = new Date( - now.getTime() + expiryDays * 24 * 60 * 60 * 1000, - ); + const expiresAt = new Date(now.getTime() + expiryDays * 24 * 60 * 60 * 1000); + const listing = this.listingRepo.create({ ...dto, userId, expiresAt }); + const variants = this.prepareVariants(dto); + if (variants.length > 0) { + listing.variants = variants; + this.aggregateListing(listing); + } + const saved = await this.listingRepo.save(listing); // Index in search service try { await this.searchSyncService.syncSingleListing(saved, 'index'); } catch (error) { - this.logger.warn( - `Failed to index listing ${saved.id} in search: ${error.message}`, - ); + this.logger.warn(`Failed to index listing ${saved.id} in search: ${error.message}`); } - return saved; + return this.aggregateListing(saved); } async importFromCsv( @@ -160,29 +235,39 @@ export class ListingsService { } async findOne(id: string) { - const listing = await this.listingRepo.findOneBy({ id }); + const listing = await this.listingRepo.findOne({ + where: { id }, + relations: ['variants'], + }); if (!listing) throw new NotFoundException('Listing not found'); + // Increment views count listing.views = (listing.views || 0) + 1; await this.listingRepo.save(listing); - return listing; + + return this.aggregateListing(listing); } async update(id: string, dto: UpdateListingDto) { const listing = await this.findOne(id); + + if (dto.variants) { + listing.variants = this.prepareVariants(dto); + } + Object.assign(listing, dto); + this.aggregateListing(listing); + const saved = await this.listingRepo.save(listing); // Update search index try { await this.searchSyncService.syncSingleListing(saved, 'update'); } catch (error) { - this.logger.warn( - `Failed to update listing ${saved.id} in search: ${error.message}`, - ); + this.logger.warn(`Failed to update listing ${saved.id} in search: ${error.message}`); } - return saved; + return this.aggregateListing(saved); } async delete(id: string) { @@ -205,21 +290,18 @@ export class ListingsService { const now = new Date(); const query = this.listingRepo .createQueryBuilder('listing') - .innerJoin('listing.user', 'user', 'user.deletedAt IS NULL') + .leftJoin('listing.user', 'user') + .leftJoinAndSelect('listing.variants', 'variant') .where('listing.isActive = :isActive', { isActive: true }) .andWhere('listing.deletedAt IS NULL') - .andWhere('(listing.expiresAt IS NULL OR listing.expiresAt > :now)', { - now, - }); + .andWhere('(listing.expiresAt IS NULL OR listing.expiresAt > :now)', { now }); if (category) { query.andWhere('listing.category = :category', { category }); } if (location) { - query.andWhere('listing.location ILIKE :location', { - location: `%${location}%`, - }); + query.andWhere('listing.location ILIKE :location', { location: `%${location}%` }); } if (minPrice !== undefined) { @@ -231,19 +313,17 @@ export class ListingsService { } if (q) { - query.andWhere( - '(listing.title ILIKE :q OR listing.description ILIKE :q)', - { q: `%${q}%` }, - ); + query.andWhere('(listing.title ILIKE :q OR listing.description ILIKE :q)', { q: `%${q}%` }); } - query.orderBy('listing.createdAt', 'DESC'); - query.take(take); - query.skip(skip); + query.orderBy('listing.createdAt', 'DESC').take(take).skip(skip); const [listings, total] = await query.getManyAndCount(); - return { listings, total }; + return { + listings: listings.map((l) => this.aggregateListing(l)), + total, + }; } async findAll(page?: number, limit?: number, category?: string) { @@ -252,7 +332,8 @@ export class ListingsService { const query = this.listingRepo .createQueryBuilder('listing') - .innerJoin('listing.user', 'user', 'user.deletedAt IS NULL') + .leftJoin('listing.user', 'user') + .leftJoinAndSelect('listing.variants', 'variant') .where('listing.isActive = :isActive', { isActive: true }) .andWhere('listing.deletedAt IS NULL'); @@ -263,18 +344,26 @@ export class ListingsService { query.orderBy('listing.createdAt', 'DESC').take(take).skip(skip); const [listings, total] = await query.getManyAndCount(); - return { listings, total, page: page || 1, limit: take }; + return { + listings: listings.map((l) => this.aggregateListing(l)), + total, + page: page || 1, + limit: take, + }; } async findFeatured() { - return this.listingRepo + const listings = await this.listingRepo .createQueryBuilder('listing') - .innerJoin('listing.user', 'user', 'user.deletedAt IS NULL') + .leftJoin('listing.user', 'user') + .leftJoinAndSelect('listing.variants', 'variant') .where('listing.isActive = :isActive', { isActive: true }) .andWhere('listing.deletedAt IS NULL') .orderBy('listing.createdAt', 'DESC') .take(10) .getMany(); + + return listings.map((l) => this.aggregateListing(l)); } async remove(id: string) { diff --git a/src/orders/entities/order.entity.ts b/src/orders/entities/order.entity.ts index d214eea..df25e2b 100644 --- a/src/orders/entities/order.entity.ts +++ b/src/orders/entities/order.entity.ts @@ -42,6 +42,7 @@ export class Order { @Column({ type: 'json', default: [] }) items: Array<{ productId: string; + variantId?: string; productName: string; quantity: number; price: number;